diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 02c3d41d..2ad4b8c1 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -15,7 +15,9 @@ import ComposableArchitecture 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 @@ -59,10 +61,12 @@ 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 - public var previewPost: @Sendable (_ request: PostPreviewRequest) async throws -> PostPreview + 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 @@ -344,6 +348,14 @@ extension APIClient: DependencyKey { let response = try await api.send(command) return try await parser.parseWriteForm(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.send(command) + return try await parser.parseTemplateSend(response: response) + }, getHistory: { offset, perPage in let response = try await api.send(MemberCommand.history(page: offset, perPage: perPage)) @@ -365,6 +377,14 @@ extension APIClient: DependencyKey { let response = try await api.send(command) return try await parser.parsePostPreview(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.send(command) + return try await parser.parseTemplatePreview(response: response) + }, sendPost: { request in let command = ForumCommand.Post.send(data: PostSendRequest( @@ -620,7 +640,10 @@ 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))) }, getHistory: { _, _ in return .mock @@ -629,10 +652,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 PreviewResponse(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 84d7a297..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: return .preview(Document()) - case .send: return .send(Document()) + case .preview(let data): return .preview(data) + case .send(let data): return .send(data) } } } 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/AnalyticsClient/Events/TopicEvent.swift b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift index 62241fbd..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 @@ -22,6 +21,7 @@ public enum TopicEvent: Event { case menuGoToEnd case menuSetFavorite case menuWritePost + case menuWritePostWithTemplate case menuPostReply(Int) case menuPostKarma(Int) 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 d7c23d3c..f500184b 100644 --- a/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift +++ b/Modules/Sources/ArticleFeature/Comments/CommentFeature.swift @@ -12,8 +12,8 @@ import PersistenceKeys import APIClient import Models import ToastClient -import WriteFormFeature import ReputationChangeFeature +import FormFeature public enum CommentContextMenuOptions { case report @@ -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) } @@ -38,8 +36,8 @@ public struct CommentFeature: Reducer, Sendable { @ObservableState public struct State: Equatable, Identifiable { @Presents public var alert: AlertState? - @Presents var writeForm: WriteFormFeature.State? @Presents var changeReputation: ReputationChangeFeature.State? + @Presents var report: FormFeature.State? @Shared(.userSession) public var userSession: UserSession? public var id: Int { return comment.id } public var comment: Comment @@ -88,8 +86,8 @@ public struct CommentFeature: Reducer, Sendable { case likeButtonTapped case changeReputationButtonTapped - case writeForm(PresentationAction) case changeReputation(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(.writeFormSent(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,10 +144,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.report = FormFeature.State( + type: .report( + id: state.comment.id, + type: .comment + ) + ) return .none case .changeReputationButtonTapped: @@ -216,8 +204,8 @@ public struct CommentFeature: Reducer, Sendable { return .none } } - .ifLet(\.$writeForm, action: \.writeForm) { - WriteFormFeature() + .ifLet(\.$report, action: \.report) { + FormFeature() } .ifLet(\.$changeReputation, action: \.changeReputation) { ReputationChangeFeature() diff --git a/Modules/Sources/ArticleFeature/Comments/CommentsView.swift b/Modules/Sources/ArticleFeature/Comments/CommentsView.swift index 8faf6c90..7f9445ca 100644 --- a/Modules/Sources/ArticleFeature/Comments/CommentsView.swift +++ b/Modules/Sources/ArticleFeature/Comments/CommentsView.swift @@ -12,8 +12,8 @@ import NukeUI import SharedUI import SkeletonUI import SFSafeSymbols -import WriteFormFeature import ReputationChangeFeature +import FormFeature // MARK: - Comments View @@ -118,9 +118,9 @@ 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 { - WriteFormScreen(store: store) + FormScreen(store: store) } } .fittedSheet( 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/BBBuilder/Sources/Tokenizator/String/BBTokenizer.swift b/Modules/Sources/BBBuilder/Sources/Tokenizator/String/BBTokenizer.swift index d021386a..38742c45 100644 --- a/Modules/Sources/BBBuilder/Sources/Tokenizator/String/BBTokenizer.swift +++ b/Modules/Sources/BBBuilder/Sources/Tokenizator/String/BBTokenizer.swift @@ -55,7 +55,7 @@ public struct BBTokenizer { if currentIndex < input.endIndex { let string = String(input[tagStartIndex..) + case destination(PresentationAction) + case upload(UploadBoxFeature.Action) + + case view(View) + public enum View { + case onAppear + case tagButtonTapped(BBPanelTag) + case fontSizeButtonTapped(Int) + case colorButtonTapped(String) + case colorCancelButtonTapped + case alertTagButtonTapped(BBPanelTag) + case hideUploadBoxButtonTapped + case returnTagsButtonTapped + } + + case delegate(Delegate) + public enum Delegate { + case tagTapped((String, String)) + } + } + + // MARK: - Body + + 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) + + state.upload.files = state.existsFiles + state.uploadedFiles = state.existsFiles.count + state.upload.allowedExtensions = state.allowedExtensions + } + 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: + state.viewState = .fontSizes + case .color: + state.destination = .colorTag + 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: + state.showUploadBox.toggle() + } + 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(.colorButtonTapped(name)): + state.destination = nil + return .send(.delegate(.tagTapped(("[COLOR=\(name)]", "[/COLOR]")))) + + case .view(.colorCancelButtonTapped): + state.destination = nil + return .none + + case let .view(.alertTagButtonTapped(tag)): + let input = state.alertInput + 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 let .upload(.delegate(.fileHasBeenTapped(id, name))): + return .send(.delegate(.tagTapped(("[attachment=\"\(id):\(name)\"]", "")))) + + case .upload(.delegate(.someFileUploading)): + 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 + + case .delegate, .destination, .binding, .upload: + 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..745c35df --- /dev/null +++ b/Modules/Sources/BBPanelFeature/BBPanelView.swift @@ -0,0 +1,286 @@ +// +// BBPanelView.swift +// ForPDA +// +// Created by Xialtal on 28.12.25. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import UploadBoxFeature + +@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 { + VStack(spacing: 32) { + if store.showUploadBox { + UploadBox() + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 24) { + switch store.viewState { + case .tags: + Tags() + case .fontSizes: + FontSizes() + } + } + .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)) { + ColorsGrid() + .presentationDetents([.medium]) + .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 Tags() -> some View { + ForEach(store.tags, id: \.self) { tag in + TagButton(tag) + } + } + + // MARK: - Tag Button + + @ViewBuilder + private func TagButton(_ tag: BBPanelTag) -> some View { + WithPerceptionTracking { + 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) + } + } + } + .buttonStyle(.plain) + } + } + + private func tagButtonColor(_ tag: BBPanelTag) -> Color { + return tag == .upload && (store.isUploading || store.showUploadBox) + ? tintColor + : Color(.Labels.primary) + } + + // MARK: - Font Sizes + + @ViewBuilder + 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: - Colors Grid + + @ViewBuilder + 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() + .fill(color.color) + .frame(width: 44, height: 44) + .overlay( + Circle() + .stroke(Color(.Separator.secondary), lineWidth: color == .white ? 1 : 0) + ) + .onTapGesture { + send(.colorButtonTapped(color.title)) + } + } + } + + Spacer() + } + .padding(.horizontal, 16) + } + } + + // 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()) + ) + } + } + + WithPerceptionTracking { + UploadBoxView(store: store.scope(state: \.upload, action: \.upload)) + .padding(.bottom, 32) + } + } + .padding(.top, 16) + .padding(.horizontal, 16) + .background { + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 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) + + Button(LocalizedStringResource("Cancel", bundle: .module)) { } + + Button(LocalizedStringResource("OK", bundle: .module)) { + action() + } + .disabled(store.alertInput.isEmpty) + } +} + +// MARK: - Previews + +#Preview { + BBPanelView( + store: Store( + initialState: BBPanelFeature.State( + for: .post(isCurator: true, canModerate: true), + supportsUpload: true + ), + ) { + BBPanelFeature() + } + ) +} diff --git a/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift new file mode 100644 index 00000000..710e4922 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderFeature.swift @@ -0,0 +1,97 @@ +// +// 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: [String] = [""] + + var isAddItemButtonDisabled: Bool { + return listItems.contains(where: { $0.isEmpty }) + } + + 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.focus = .item(0) + return .none + + case .view(.addListItemButtonTapped): + let newId = state.listItems.count + state.listItems.append("") + 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)") + } + 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..fe2c7c0b --- /dev/null +++ b/Modules/Sources/BBPanelFeature/LIstBuilder/ListTagBuilderView.swift @@ -0,0 +1,146 @@ +// +// ListTagBuilderView.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI + +@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 { + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List { + Section { + ForEach(0.. 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 + 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) + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + ListTagBuilderView( + store: Store( + initialState: ListTagBuilderFeature.State( + isBullet: true + ), + ) { + ListTagBuilderFeature() + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} 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/Models/BBPanelTag.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift new file mode 100644 index 00000000..91ae0778 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/Models/BBPanelTag.swift @@ -0,0 +1,105 @@ +// +// 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 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 + } + } +} diff --git a/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift b/Modules/Sources/BBPanelFeature/Models/BBPanelType.swift new file mode 100644 index 00000000..b2cb1818 --- /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 [.b, .i, .u, .s, .quote, .code] + case .post: + return [ + .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, /*.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..5294ad65 --- /dev/null +++ b/Modules/Sources/BBPanelFeature/Resources/Localizable.xcstrings @@ -0,0 +1,116 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Add item" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить пункт" + } + } + } + }, + "Attachments" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вложения" + } + } + } + }, + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "Create" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать" + } + } + } + }, + "Input full URL-address" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите полный URL-адрес" + } + } + } + }, + "Input spoiler title" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите заголовок спойлера" + } + } + } + }, + "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" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + } + } + }, + "Select color" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите цвет" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings similarity index 83% rename from Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings rename to Modules/Sources/FormFeature/Resources/Localizable.xcstrings index c9646642..38049a85 100644 --- a/Modules/Sources/WriteFormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings @@ -47,6 +47,16 @@ } } }, + "Input reason" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите причину" + } + } + } + }, "It will be attached as a dialog to your last post" : { "localizations" : { "ru" : { @@ -99,44 +109,38 @@ } } }, - "OK" : { + "Not all required fields are filled in" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ОК" + "value" : "Заполните все обязательные поля" } } } }, - "Oops, error with loading preview :(" : { + "OK" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oops, error with loading preview :(" - } - }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ой, произошла ошибка при загрузке превью :(" + "value" : "ОК" } } } }, - "Oops, error with loading title :(" : { + "Oops, error with loading preview :(" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Oops, error with loading title :(" + "value" : "Oops, error with loading preview :(" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ой, ошибка при загрузке заголовка :(" + "value" : "Ой, произошла ошибка при загрузке превью :(" } } } @@ -203,18 +207,12 @@ } } }, - "Select files..." : { + "Report is too short" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Select files..." - } - }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Выберите файлы…" + "value" : "Слишком короткая жалоба" } } } @@ -245,6 +243,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/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift b/Modules/Sources/FormFeature/Sources/Analytics/FormFeature+Analytics.swift similarity index 59% rename from Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift rename to Modules/Sources/FormFeature/Sources/Analytics/FormFeature+Analytics.swift index 181103fd..70a69f18 100644 --- a/Modules/Sources/WriteFormFeature/Analytics/WriteFormFeature+Analytics.swift +++ b/Modules/Sources/FormFeature/Sources/Analytics/FormFeature+Analytics.swift @@ -8,31 +8,31 @@ import ComposableArchitecture import AnalyticsClient -extension WriteFormFeature { +extension FormFeature { struct Analytics: Reducer { - typealias State = WriteFormFeature.State - typealias Action = WriteFormFeature.Action + 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, .internal: + case .binding, .destination, .rows, .internal: break - case .delegate(.writeFormSent): - analytics.log(WriteFormEvent.writeFormSent) + case .delegate(.formSent): + analytics.log(FormEvent.formSent) case .view(.publishButtonTapped): - analytics.log(WriteFormEvent.publishTapped) + analytics.log(FormEvent.publishTapped) - case .view(.dismissButtonTapped): - analytics.log(WriteFormEvent.dismissTapped) + case .view(.cancelButtonTapped): + analytics.log(FormEvent.dismissTapped) case .view(.previewButtonTapped): - analytics.log(WriteFormEvent.previewTapped) + analytics.log(FormEvent.previewTapped) case .view: break diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift new file mode 100644 index 00000000..9f4ac3b4 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift @@ -0,0 +1,151 @@ +// +// FormCheckBoxFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture +import Models + +// 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: FormFieldFlag + let options: [String] + + var selectedOptions: [Int: Bool] + + public init( + id: Int, + title: String, + description: String, + flag: FormFieldFlag, + 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: .required, + options: ["Yes", "No"] + ) + ) { + FormCheckBoxListFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormDropdownFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormDropdownFeature.swift new file mode 100644 index 00000000..0b4a8b74 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormDropdownFeature.swift @@ -0,0 +1,152 @@ +// +// FormDropdownFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import Models + +// 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: FormFieldFlag + let options: [String] + public var selectedOption: String + + public init( + id: Int, + title: String, + description: String, + flag: FormFieldFlag, + options: [String] + ) { + self.id = id + self.title = title + self.description = description + self.flag = flag + self.options = options + self.selectedOption = options.first ?? "" + } + + func getValue() -> FormValue { + return .integer(options.firstIndex(of: selectedOption)! + 1) + } + + 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: isLiquidGlass ? 28 : 14) + .fill(Color(.Background.teritary)) + ) + .overlay { + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 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: .required, + options: ["New version", "Beta", "Modification", "Other"] + ) + ) { + FormDropdownFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift new file mode 100644 index 00000000..ae09e0c0 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormEditorFeature.swift @@ -0,0 +1,208 @@ +// +// FormFormFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import Models +import BBPanelFeature + +// MARK: - Feature + +@Reducer +public struct FormEditorFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + + var bbPanel: BBPanelFeature.State + + public let id: Int + let title: String + let description: String + let placeholder: String + let flag: FormFieldFlag + let uploadBox: FormStickedUploadBox? + public var text = "" + public var textRange: NSRange? = nil + + var focus: Int? = nil + + public init( + id: Int, + title: String = "", + description: String = "", + placeholder: String = "", + flag: FormFieldFlag, + defaultText: String = "", + uploadBox: FormStickedUploadBox? = nil + ) { + self.id = id + self.title = title + self.description = description + self.placeholder = placeholder + 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 { + 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 + } + } + + // MARK: - Action + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case bbPanel(BBPanelFeature.Action) + + case view(View) + public enum View { + case onAppear + } + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Scope(state: \.bbPanel, action: \.bbPanel) { + BBPanelFeature() + } + + 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))), + .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 + } + } +} + +// MARK: - View + +@ViewAction(for: FormEditorFeature.self) +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( + content: $store.text, + placeholder: LocalizedStringResource(stringLiteral: store.placeholder), + focusEqual: store.id, + focus: $focusedField, + 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) + } + } + } +} + +// 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: .required, + defaultText: "Editor Default Text" + ) + ) { + FormEditorFeature() + }, + focusedField: $focusedField + ) +} diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormFieldConformable.swift b/Modules/Sources/FormFeature/Sources/Fields/FormFieldConformable.swift new file mode 100644 index 00000000..ec1df85b --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormFieldConformable.swift @@ -0,0 +1,22 @@ +// +// FormFieldConformable.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +import Models + +protocol FormFieldConformable: Identifiable { + var flag: FormFieldFlag { get } + var isRequired: Bool { get } + + func isValid() -> Bool + func getValue() -> FormValue +} + +extension FormFieldConformable { + var isRequired: Bool { + return flag.contains(.required) + } +} diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormFieldFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormFieldFeature.swift new file mode 100644 index 00000000..162d83a0 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormFieldFeature.swift @@ -0,0 +1,204 @@ +// +// FormFieldFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture +import Models + +@Reducer +public struct FormFieldFeature: Reducer { + + // MARK: - State + + @ObservableState + public enum State: Equatable, Identifiable, FormFieldConformable { + var flag: FormFieldFlag { return [] } + + case checkBoxList(FormCheckBoxListFeature.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 .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 + case .title(let state): return state.id + case .uploadBox(let state): return state.id + } + } + + func getValue() -> FormValue { + switch self { + case .checkBoxList(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 .checkBoxList(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 .checkBoxList(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 checkBoxList(FormCheckBoxListFeature.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: \.checkBoxList, action: \.checkBoxList) { + FormCheckBoxListFeature() + } + 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 .checkBoxList: + if let store = store.scope(state: \.checkBoxList, action: \.checkBoxList) { + FormCheckBoxListRow(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/Sources/Fields/FormTextFieldFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormTextFieldFeature.swift new file mode 100644 index 00000000..a705a373 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormTextFieldFeature.swift @@ -0,0 +1,123 @@ +// +// FormTextFieldFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import Models + +// 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: FormFieldFlag + let maxLength: Int? + public var text = "" + + public init( + id: Int, + title: String = "", + description: String = "", + placeholder: String = "", + flag: FormFieldFlag, + defaultText: String = "", + maxLength: Int? = nil + ) { + self.id = id + self.title = title + self.description = description + self.placeholder = placeholder + self.flag = flag + self.text = defaultText + self.maxLength = maxLength + } + + func getValue() -> FormValue { + return .string(text) + } + + func isValid() -> Bool { + return isRequired ? !text.isEmpty : true + } + } + + // 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( + content: $store.text, + placeholder: LocalizedStringResource(stringLiteral: store.placeholder), + focusEqual: store.id, + focus: $focusedField, + characterLimit: store.maxLength + ) + } + } + } + } +} + +// 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: .required, + defaultText: "TextField Default Text" + ) + ) { + FormTextFieldFeature() + }, + focusedField: $focusedField + ) +} diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormTitleFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormTitleFeature.swift new file mode 100644 index 00000000..2bf1e3e6 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormTitleFeature.swift @@ -0,0 +1,118 @@ +// +// FormTitleFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture +import Models + +// MARK: - Feature + +@Reducer +public struct FormTitleFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public let id: Int + let text: String + let flag: FormFieldFlag = [] + + public init(id: Int, text: String) { + self.id = id + self.text = text + } + + var nodes: [FormNode] = [] + + func getValue() -> FormValue { + return .integer(0) + } + + 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 { + 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) + } + } else { + EmptyView() + } + } + } +} + +// 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/Sources/Fields/FormUploadBoxFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormUploadBoxFeature.swift new file mode 100644 index 00000000..7366236a --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Fields/FormUploadBoxFeature.swift @@ -0,0 +1,168 @@ +// +// FormUploadBoxFeature.swift +// FormFeature +// +// Created by Ilia Lubianoi on 19.07.2025. +// + +import SwiftUI +import ComposableArchitecture +import UploadBoxFeature +import Models + +// MARK: - Feature + +@Reducer +public struct FormUploadBoxFeature: Reducer { + + // MARK: - State + + @ObservableState + public struct State: Equatable, FormFieldConformable { + public var upload = UploadBoxFeature.State(type: .form) + + public let id: Int + let title: String + let description: String + let flag: FormFieldFlag + let allowedExtensions: [String] + let isHidden: Bool + public var isLocked: Bool + + var uploadedFilesIds: [Int] = [] + + public init( + id: Int, + title: String, + description: String, + flag: FormFieldFlag, + allowedExtensions: [String], + isHidden: Bool, + isLocked: Bool = false + ) { + self.id = id + self.title = title + self.description = description + self.flag = flag + self.allowedExtensions = allowedExtensions + self.isHidden = isHidden + self.isLocked = isLocked + } + + func getValue() -> FormValue { + return .array(uploadedFilesIds.map { .integer($0) }) + } + + func isValid() -> Bool { + if isLocked { return false } + return isRequired ? !uploadedFilesIds.isEmpty : true + } + } + + // MARK: - Actions + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case upload(UploadBoxFeature.Action) + + case view(View) + public enum View { + case onAppear + } + + case delegate(Delegate) + public enum Delegate { + case anyFileUploading(Bool) + } + } + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Scope(state: \.upload, action: \.upload) { + UploadBoxFeature() + } + + Reduce { state, action in + switch action { + case .upload(.delegate(.someFileUploading)): + return .send(.delegate(.anyFileUploading(true))) + + case .upload(.delegate(.allFilesAreUploaded)): + return .send(.delegate(.anyFileUploading(false))) + + case let .upload(.delegate(.fileHasBeenUploaded(id))): + state.uploadedFilesIds.append(id) + + case let .upload(.delegate(.fileHasBeenRemoved(id))): + state.uploadedFilesIds.removeAll(where: { $0 == id }) + + case .view(.onAppear): + state.upload.allowedExtensions = state.allowedExtensions + + case .binding, .upload, .delegate: + break + } + return .none + } + } +} + +// MARK: - View + +@ViewAction(for: FormUploadBoxFeature.self) +struct FormUploadBoxRow: View { + + // MARK: - Properties + + @Perception.Bindable var store: StoreOf + @Environment(\.tintColor) private var tintColor + + // MARK: - Body + + var body: some View { + WithPerceptionTracking { + 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) + } + } + } + } +} + +// 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: .required, + allowedExtensions: ["jpg", "jpeg", "gif", "png"], + isHidden: false + ) + ) { + FormUploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/FormFeature/Sources/FormFeature.swift b/Modules/Sources/FormFeature/Sources/FormFeature.swift new file mode 100644 index 00000000..b7477ee6 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/FormFeature.swift @@ -0,0 +1,616 @@ +// +// 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 + 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 + public var isEditingReasonEnabled = false + public var editReasonText = "" + + var isFormLocked = false + + var canShowShowMark = false + var isShowMarkEnabled = false + + public var inPostEditingMode: Bool { + if case let .post(type, _, _) = type, case .edit = type { + return true + } + return false + } + + 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 + } + + var content: [FormValue] { + if rows.count == 1, case let .editor(editorState) = rows.first { + let attachments = editorState.getAttachments() + 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 { + content.append(row.getValue()) + } + } + return content + } + } + + 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<[FormFieldType], any Error>) + case reportResponse(Result) + case simplePostResponse(Result) + case templateResponse(Result) + case publishForm(flag: PostSendFlag) + } + + case delegate(Delegate) + @CasePathable + public enum Delegate { + case formSent(FormSend) + } + } + + // 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(.publishForm(flag: PostSendFlag(rawValue: editorFlag)!))) + + case .destination(.dismiss): + state.isPublishing = false + + case .destination: + break + + case .delegate(.formSent): + return .run { _ in await dismiss() } + + 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): + if state.inPostEditingMode, + let userId = state.userSession?.userId, + let user = cacheClient.getUser(userId), + user.canSetShowMarkOnPostEdit { + state.canShowShowMark = true + } + + switch content { + case let .simple(content, attachments): + let editorState = FormEditorFeature.State( + id: 0, + flag: [.required, .uploadable], + defaultText: content, + uploadBox: .init(id: 1, existsAttachments: attachments, allowedExtensions: []) + ) + 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: .required, uploadBox: nil) + 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 = 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("Bad simple post content! \(state.content)") + } + } else { + FormType.PostContentType.template(state.content) + } + + previewState = FormPreviewFeature.State( + formType: .post(type: type, topicId: topicId, content: content) + ) + + case .report: + let content = if case let .string(text) = state.content.first { text } else { + fatalError("Report content field should contains only one .string()!") + } + previewState = FormPreviewFeature.State( + formType: .post(type: .new, topicId: 0, content: .simple(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(.publishForm(flag: .default))) + + case let .internal(.loadForm(id: id, isTopic: isTopic)): + return .run { send in + let request = ForumTemplateRequest(id: id, action: .get) + let result = await Result { try await apiClient.getTemplate(request, isTopic) } + await send(.internal(.formResponse(result))) + } catch: { error, send in + await send(.internal(.formResponse(.failure(error)))) + } + + 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.contains(.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): + // 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)) + + 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, + maxLength: maxLength + ) + 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, + uploadBox: index == combined?.editorId ? combined?.uploadBox : nil + ) + state.rows.append(.editor(editorState)) + + case let .checkboxList(content, options): + let checkboxListState = FormCheckBoxListFeature.State( + id: index, + title: content.name, + description: content.description, + flag: content.flag, + options: options + ) + state.rows.append(.checkBoxList(checkboxListState)) + + 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, + isHidden: index == combined?.uploadBox?.id + ) + state.rows.append(.uploadBox(uploadboxState)) + } + } + state.isFormLoading = false + + case let .internal(.formResponse(.failure(error))): + print(error) + state.isFormLoading = false + state.destination = .alert(.unknownError) + + 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): + 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, content, isTopic) } + await send(.internal(.templateResponse(result))) + } + + 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("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)") + } + return .run { [ + content = 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): + 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))) + } + + default: + fatalError() + } + + 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 + state.destination = .alert(.unknownError) + analyticsClient.capture(error) + + case let .internal(.simplePostResponse(.success(.success(post)))): + return .send(.delegate(.formSent(.post(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(.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 + state.destination = .alert(.unknownError) + analyticsClient.capture(error) + } + + return .none + } + .ifLet(\.$destination, action: \.destination) + .forEach(\.rows, action: \.rows) { + FormFieldFeature() + } + + Analytics() + } +} + +extension FormFeature.Destination.State: Equatable {} + +// MARK: - Alerts + +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: { + 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") + } + + // 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: { + ButtonState { + TextState("OK") + } + } +} diff --git a/Modules/Sources/FormFeature/Sources/FormScreen.swift b/Modules/Sources/FormFeature/Sources/FormScreen.swift new file mode 100644 index 00000000..fbf42b0d --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/FormScreen.swift @@ -0,0 +1,250 @@ +// +// 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.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) + .modifier(DestinationModifier(store: store)) // extracted to modifier, due to .alert() compilation error + .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 || store.isFormLocked) + .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) + .disabled(store.isFormLocked) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + send(.previewButtonTapped) + } label: { + Image(systemSymbol: .eye) + .font(.body) + .frame(width: 34, height: 22) + } + .tint(tintColor) + .disabled(store.isPreviewButtonDisabled || store.isFormLocked) + } + } + + // 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: - 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)") { + 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 .mock + } + } + ) + } + .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, + .mockRequiredText, + .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/Sources/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift new file mode 100644 index 00000000..e5ee57a7 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift @@ -0,0 +1,144 @@ +// +// FormPreviewFeature.swift +// ForPDA +// +// Created by Xialtal on 16.03.25. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models +import TopicBuilder +import SharedUI + +@Reducer +public struct FormPreviewFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + public let formType: FormType + + var contentTypes: [UITopicType] = [] + var attachments: [Attachment] = [] + + var isPreviewLoading = false + + public init( + formType: FormType + ) { + self.formType = formType + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + case cancelButtonTapped + } + + 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 + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + switch state.formType { + case .topic(let forumId, let 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(.internal(.loadSimplePreview( + postId: postId, + topicId: topicId, + content: content, + attIds: attachments + ))) + + case .template(let content): + return .send(.internal(.loadPreview(id: topicId, content: content))) + } + + case .report(_, _): + // handling as .post + break + } + return .none + + case .view(.cancelButtonTapped): + return .run { _ in await dismiss() } + + 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( + id: id, + content: try FormValue.toDocument(content), + isTopic: isTopic + )} + await send(.internal(.previewResponse(result))) + } catch: { error, send in + await send(.internal(.previewResponse(.failure(error)))) + } + + case let .internal(.loadSimplePreview(postId, topicId, content, attachments)): + state.isPreviewLoading = true + return .run { send in + let result = await Result { try await apiClient.previewPost( + request: PostPreviewRequest( + id: postId, + post: PostRequest( + topicId: topicId, + content: content, + flag: 0, + attachments: attachments + ) + ) + )} + await send(.internal(.previewResponse(result))) + } catch: { error, send in + await send(.internal(.previewResponse(.failure(error)))) + } + + case let .internal(.previewResponse(.success(preview))): + state.contentTypes = TopicNodeBuilder( + text: preview.content, attachments: preview.attachments + ).build() + state.attachments = preview.attachments + + state.isPreviewLoading = false + + return .none + + case let .internal(.previewResponse(.failure(error))): + analyticsClient.capture(error) + return .send(.view(.cancelButtonTapped)) + } + } + } +} diff --git a/Modules/Sources/FormFeature/Sources/Preview/FormPreviewView.swift b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewView.swift new file mode 100644 index 00000000..b3fda022 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewView.swift @@ -0,0 +1,95 @@ +// +// FormPreviewView.swift +// ForPDA +// +// Created by Xialtal on 16.03.25. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import Models +import TopicBuilder + +@ViewAction(for: FormPreviewFeature.self) +struct FormPreviewView: View { + + @Perception.Bindable var store: StoreOf + + @Environment(\.tintColor) private var tintColor + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !store.contentTypes.isEmpty { + ForEach(store.contentTypes, id: \.self) { type in + 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 { + 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)) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + Text("Cancel", bundle: .module) + } + } + } + .overlay { + if store.isPreviewLoading && store.contentTypes.isEmpty { + PDALoader() + .frame(width: 24, height: 24) + } + } + .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)) +} diff --git a/Modules/Sources/FormFeature/Sources/Support/FormAttachment.swift b/Modules/Sources/FormFeature/Sources/Support/FormAttachment.swift new file mode 100644 index 00000000..cc5571d5 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/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/Sources/Support/FormNodeBuilder.swift b/Modules/Sources/FormFeature/Sources/Support/FormNodeBuilder.swift new file mode 100644 index 00000000..12e7927b --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Support/FormNodeBuilder.swift @@ -0,0 +1,51 @@ +// +// FormNodeBuilder.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +import SharedUI +import SwiftUI +import TopicBuilder + +typealias FormNode = UITopicType + +// MARK: - Builder + +struct FormNodeBuilder { + + private let text: String + + init(text: String) { + self.text = text + } + + func build(isDescription: Bool = false) -> [UITopicType] { + var text = text + if isDescription { + text = "[color=gray][size=1]\(text)[/size][/color]" + } + return TopicNodeBuilder(text: text, attachments: []).build() + } +} + +// MARK: - View + +struct FormNodeView: View { + + let node: UITopicType + + var body: some View { + TopicView( + type: node, + attachments: [], + onUrlTap: { _ in + // 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. + } + ) + } +} diff --git a/Modules/Sources/FormFeature/Sources/Support/FormStickedUploadBox.swift b/Modules/Sources/FormFeature/Sources/Support/FormStickedUploadBox.swift new file mode 100644 index 00000000..4618b761 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Support/FormStickedUploadBox.swift @@ -0,0 +1,22 @@ +// +// FormStickedUploadBox.swift +// ForPDA +// +// Created by Xialtal on 2.03.26. +// + +public struct FormStickedUploadBox: Sendable, Equatable { + public let id: Int + public let existsAttachments: [FormAttachment] + public let allowedExtensions: [String] + + public init( + id: Int, + existsAttachments: [FormAttachment] = [], + allowedExtensions: [String] + ) { + self.id = id + self.existsAttachments = existsAttachments + self.allowedExtensions = allowedExtensions + } +} diff --git a/Modules/Sources/FormFeature/Sources/Support/FormType.swift b/Modules/Sources/FormFeature/Sources/Support/FormType.swift new file mode 100644 index 00000000..0f1e889f --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Support/FormType.swift @@ -0,0 +1,28 @@ +// +// 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: [FormValue]) + + public enum PostType: Sendable, Equatable { + case new + case edit(postId: Int) + } + + public enum PostContentType: Sendable, Equatable { + case simple(String, [FormAttachment]) + case template([FormValue]) + } + + public var isTopic: Bool { + if case .topic = self { true } else { false } + } +} diff --git a/Modules/Sources/FormFeature/Sources/Support/FormValue.swift b/Modules/Sources/FormFeature/Sources/Support/FormValue.swift new file mode 100644 index 00000000..13e4fb44 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Support/FormValue.swift @@ -0,0 +1,54 @@ +// +// FormValue.swift +// ForPDA +// +// Created by Xialtal on 22.02.26. +// + +import APIClient + +public enum FormValue: Sendable, Hashable { + case string(String) + case integer(Int) + + case array([FormValue]) +} + +extension FormValue { + static func toDocument(_ values: [FormValue]) throws -> PDAPIDocument { + let document = PDAPIDocument() + for value in values { + try document.append(value) + } + 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 { + func append(_ value: FormValue) throws { + switch value { + case .string(let string): + _ = try append(string) + + case .integer(let int): + _ = try append(int) + + case .array(let array): + let nestedDocument = PDAPIDocument() + for element in array { + try nestedDocument.append(element) + } + _ = try append(nestedDocument) + } + } +} diff --git a/Modules/Sources/FormFeature/Sources/Views/CheckBox.swift b/Modules/Sources/FormFeature/Sources/Views/CheckBox.swift new file mode 100644 index 00000000..4573d9f4 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/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/Sources/Views/EditReasonView.swift b/Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift new file mode 100644 index 00000000..4eaa6ab3 --- /dev/null +++ b/Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift @@ -0,0 +1,84 @@ +// +// EditReasonView.swift +// FormFeature +// +// Created by Ilia Lubianoi on 20.07.2025. +// + +import SwiftUI +import SharedUI + +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( + content: $text, + placeholder: LocalizedStringResource("Input reason", bundle: .module), + focusEqual: id, + focus: $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/Tests/FormFeatureTests.swift b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift new file mode 100644 index 00000000..943e3c29 --- /dev/null +++ b/Modules/Sources/FormFeature/Tests/FormFeatureTests.swift @@ -0,0 +1,528 @@ +// +// 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 FormFeatureTests { + + // 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: .required) + 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.publishForm) { + $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: .required) + 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.publishForm) { + $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: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, allowedExtensions: []) + ) + 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.publishForm) { + $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: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, allowedExtensions: []) + ) + 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.publishForm) { + $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: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, existsAttachments: [], allowedExtensions: []) + ) + 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.publishForm) { + $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.publishForm) { + $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: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 1, allowedExtensions: []) + ) + 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.publishForm) { + $0.isPublishing = true + } + + await store.receive(\.internal.simplePostResponse) { + $0.isPublishing = false + $0.destination = .alert(.unknownError) + } + } + + // 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", [attachment]) + ) + ) + ) { + FormFeature() + } withDependencies: { + $0.apiClient.editPost = { _ in + return .success(PostSend(id: 0, topicId: 0, offset: 0)) + } + } + + 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 + } + + #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.publishForm) { + $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") + let title1 = FormTitleFeature.State(id: 1, text: "") + var dropdown = FormDropdownFeature.State( + id: 2, + title: "Тип обновления", + description: "Что публикуем?", + flag: .required, + options: [ + "Новая версия", + "Beta", + "Модификация", + "Другое" + ] + ) + var text1 = FormTextFieldFeature.State( + id: 3, + title: "Версия", + description: "Укажите версию. Например: 1.3.7", + placeholder: "", + flag: .required, + defaultText: "", + maxLength: 255 + ) + 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: .required, + defaultText: "" + ) + var editor = FormEditorFeature.State( + id: 5, + title: "Описание", + description: "Введите дополнительную полезную информацию, например для:\r\n[b]\"Новая версия\"[/b] - список \"что нового\".\r\n[b]\"Модификация\"[/b] - \"на чем основано\", \"особенности\", \"обновлено\". ", + placeholder: "", + flag: [.required, .uploadable], + defaultText: "", + uploadBox: .init(id: 6, allowedExtensions: ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"]) + ) + var uploadbox = FormUploadBoxFeature.State( + id: 6, + title: "Файлы", + description: "", + flag: [.required, .uploadable], + allowedExtensions: ["apk", "apks", "exe", "zip", "rar", "obb", "7z", "r00", "r01", "apkm", "ipa"], + isHidden: true + ) + + 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), + .title(title1), + .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(.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) + + await store.send(.view(.publishButtonTapped)) + + await store.receive(\.internal.publishForm) { + $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/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index d8cb3995..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): @@ -52,6 +52,8 @@ extension ForumFeature { break // TODO: Add case .toBookmarks: break // TODO: Add + case .createTopic: + break // TODO: Add } case let .view(.contextTopicMenu(option, topic)): diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index c6924be4..4df20884 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,10 @@ public struct ForumFeature: Reducer, Sendable { case let .pageNavigation(.offsetChanged(to: newOffset)): return .send(.internal(.loadForum(offset: newOffset))) - case .pageNavigation: + case let .destination(.presented(.form(.delegate(.formSent(.topic(id)))))): + return .send(.delegate(.openTopic(id: id, name: "", goTo: .first))) + + case .destination, .pageNavigation: return .none case .view(.onFirstAppear): @@ -197,8 +211,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 +332,7 @@ public struct ForumFeature: Reducer, Sendable { return .none } } + .ifLet(\.$destination, action: \.destination) Analytics() } @@ -320,3 +345,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, 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 9a7616b0..d4b11de1 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/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/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/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 deleted file mode 100644 index 313ec990..00000000 --- a/Modules/Sources/Models/Common/WriteFormFieldType.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// WriteFormFieldType.swift -// ForPDA -// -// Created by Xialtal on 14.03.25. -// - -public enum WriteFormFieldType: Sendable, Equatable, Hashable { - case title(String) - case text(FormField) - case editor(FormField) - case dropdown(FormField, _ options: [String]) - case uploadbox(FormField, _ extensions: [String]) - case checkboxList(FormField, _ options: [String]) - - public struct FormField: Sendable, Equatable, Hashable { - public let name: String - public let description: String - public let example: String - public let flag: Int - public let defaultValue: String - - public var isRequired: Bool { - return flag & 1 != 0 - } - - public var isVisible: Bool { - return flag & 2 != 0 - } - - public init( - name: String, - description: String, - example: String, - flag: Int, - defaultValue: String - ) { - self.name = name - self.description = description - self.example = example - self.flag = flag - self.defaultValue = defaultValue - } - } -} - -public extension WriteFormFieldType { - static let mockTitle: WriteFormFieldType = - .title("[b]This is absolute simple title[/b]") - - static let mockText: WriteFormFieldType = .text( - FormField( - name: "Topic name", - description: "Enter topic name.", - example: "Starting from For, ends with PDA", - flag: 1, - defaultValue: "" - ) - ) - - static let mockEditor: WriteFormFieldType = .editor( - FormField( - name: "Topic content", - description: "This field contains topic [color=red]hat[/color] content.", - example: "ForPDA Forever!", - flag: 1, - defaultValue: "" - ) - ) - - static let mockEditorSimple: WriteFormFieldType = .editor( - FormField( - name: "", - description: "", - example: "Post text...", - flag: 0, - defaultValue: "" - ) - ) -} diff --git a/Modules/Sources/Models/Common/WriteFormForType.swift b/Modules/Sources/Models/Common/WriteFormForType.swift deleted file mode 100644 index 150d0e10..00000000 --- a/Modules/Sources/Models/Common/WriteFormForType.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// WriteFormForType.swift -// ForPDA -// -// Created by Xialtal on 14.03.25. -// - -import Foundation - -public enum WriteFormForType: Sendable, Equatable { - 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 - case edit(postId: Int) - } - - public enum PostContentType: Sendable, Equatable { - case template([String]) - case simple(String, [Int]) - } -} diff --git a/Modules/Sources/Models/Common/WriteFormSend.swift b/Modules/Sources/Models/Common/WriteFormSend.swift deleted file mode 100644 index f6bbdc51..00000000 --- a/Modules/Sources/Models/Common/WriteFormSend.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// WriteFormSend.swift -// ForPDA -// -// Created by Xialtal on 18.03.25. -// - -public enum WriteFormSend: Sendable { - case post(PostSendResponse) - case report(ReportResponseType) -} 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 new file mode 100644 index 00000000..110afe61 --- /dev/null +++ b/Modules/Sources/Models/Form/FormFieldType.swift @@ -0,0 +1,172 @@ +// +// FormFieldType.swift +// ForPDA +// +// Created by Xialtal on 14.03.25. +// + +public enum FormFieldType: Sendable, Equatable, Hashable { + case title(String) + case text(FormField, maxLenght: Int?) + case editor(FormField) + case dropdown(FormField, _ options: [String]) + case uploadbox(FormField, _ extensions: [String]) + 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 + public let flag: FormFieldFlag + public let defaultValue: String + + public init( + id: Int, + name: String, + description: String, + example: String, + flag: FormFieldFlag, + defaultValue: String + ) { + self.id = id + self.name = name + self.description = description + self.example = example + self.flag = flag + self.defaultValue = defaultValue + } + } +} + +// MARK: - Mocks + +public extension FormFieldType { + + static let mockTitle: FormFieldType = + .title("This is an absolute [b]simple[/b] [i]title[/i]") + + static let mockRequiredText: FormFieldType = .text( + FormField( + id: 0, + name: "Topic name", + description: "Enter topic name", + example: "Starting from For, ends with PDA", + flag: .required, + defaultValue: "" + ), + maxLenght: 255 + ) + + static let mockRequiredEditor: FormFieldType = .editor( + FormField( + id: 0, + name: "Topic content", + description: "This [B]field[/B] contains topic [color=red]hat[/color] content", + example: "ForPDA Forever!", + flag: .required, + defaultValue: "" + ) + ) + + static let mockEditor: FormFieldType = .editor( + FormField( + id: 0, + name: "", + description: "", + example: "Post text...", + flag: [], + defaultValue: "" + ) + ) + + static let mockUploadBox: FormFieldType = .uploadbox( + .init( + id: 0, + name: "Device photos", + description: "Upload device photos. Allowed formats JPG, GIF, PNG", + example: "", + flag: .required, + defaultValue: "" + ), + ["jpg", "gif", "png"] + ) +} + +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( + .init( + id: 2, + name: "Тип обновления", + description: "Что публикуем?", + example: "", + flag: .required, + defaultValue: "" + ), + [ + "Новая версия", + "Beta", + "Модификация", + "Другое" + ] + ), + .text( + .init( + id: 3, + name: "Версия", + description: "Укажите версию. Например: 1.3.7", + example: "", + flag: .required, + defaultValue: "" + ), + maxLenght: 255 + ), + .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: .required, + defaultValue: "" + ), + maxLenght: nil + ), + .editor( + .init( + id: 5, + name: "Описание", + description: "Введите дополнительную полезную информацию, например для:\r\n[b]\"Новая версия\"[/b] - список \"что нового\".\r\n[b]\"Модификация\"[/b] - \"на чем основано\", \"особенности\", \"обновлено\". ", + example: "", + flag: [.required, .uploadable], + defaultValue: "" + ) + ), + .uploadbox( + .init( + id: 6, + name: "Файлы", + description: "", + example: "", + flag: [.required, .uploadable], + defaultValue: "" + ), + [ + "apk", + "apks", + "exe", + "zip", + "rar", + "obb", + "7z", + "r00", + "r01", + "apkm", + "ipa" + ] + ) + ] +} diff --git a/Modules/Sources/Models/Form/FormSend.swift b/Modules/Sources/Models/Form/FormSend.swift new file mode 100644 index 00000000..500f2acc --- /dev/null +++ b/Modules/Sources/Models/Form/FormSend.swift @@ -0,0 +1,12 @@ +// +// FormSend.swift +// ForPDA +// +// Created by Xialtal on 18.03.25. +// + +public enum FormSend: Sendable { + case post(PostSend) + case topic(Int) + case report +} diff --git a/Modules/Sources/Models/Forum/Forum.swift b/Modules/Sources/Models/Forum/Forum.swift index 1094994d..2d2cb156 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/PreviewResponse.swift b/Modules/Sources/Models/Forum/PreviewResponse.swift new file mode 100644 index 00000000..ab5f895c --- /dev/null +++ b/Modules/Sources/Models/Forum/PreviewResponse.swift @@ -0,0 +1,19 @@ +// +// PreviewResponse.swift +// ForPDA +// +// Created by Xialtal on 27.02.26. +// + +public struct PreviewResponse: 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/Models/Forum/TemplateSend.swift b/Modules/Sources/Models/Forum/TemplateSend.swift new file mode 100644 index 00000000..c0414039 --- /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 failure(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 .failure = self { + true + } else { false } + } +} diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 91f8015f..dab89bec 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 @@ -85,7 +86,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 @@ -100,6 +102,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 } @@ -123,7 +126,8 @@ public extension Topic { ], navigation: [ ForumInfo(id: 1, name: "iOS - Apps", flag: 32) - ] + ], + postTemplateName: "New update" ) } 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 new file mode 100644 index 00000000..8f484ca7 --- /dev/null +++ b/Modules/Sources/ParsingClient/Parsers/FormParser.swift @@ -0,0 +1,144 @@ +// +// FormParser.swift +// ForPDA +// +// Created by Xialtal on 14.03.25. +// + +import Foundation +import Models + +public struct FormParser { + + public static func parse(from string: String) throws(ParsingError) -> [FormFieldType] { + 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 fields = array[safe: 2] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return try parseFormFields(fields) + } + + public static func parseTemplatePreview(from string: String) throws(ParsingError) -> PreviewResponse { + 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 attachmentsRaw = template[safe: 3] as? [[Any]], + let attachments = try? AttachmentParser.parseAttachment(attachmentsRaw) else { + throw ParsingError.failedToCastFields + } + + return PreviewResponse(content: content, attachments: attachments) + } + + 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. + 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 { + return .success(.topic(id: array[safe: 2] as! Int)) + } + + case 5: + guard let errors = array[safe: 2] as? [Any] else { + throw ParsingError.failedToCastFields + } + return .failure(.fieldsError(errors.description)) + + case 3: + return .failure(.badParam) + + case 4: + return .failure(.sentToPremod) + + default: + return .failure(.status(status)) + } + } + + 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, + let description = field[safe: 2] as? String, + let example = field[safe: 3] as? String, + let flag = field[safe: 4] as? Int, + let defaultValue = field[safe: 5] as? String else { + throw ParsingError.failedToCastFields + } + + let content = FormFieldType.FormField( + id: index, + name: name, + description: description, + example: example, + flag: FormFieldFlag(rawValue: flag), + defaultValue: defaultValue + ) + + switch type { + case "text", "editor": + 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 { + throw ParsingError.failedToCastFields + } + formFields.append(type == "dropdown" ? .dropdown(content, options) : .checkboxList(content, options)) + + case "upload_box": + guard let extensions = field[7] as? [String] else { + throw ParsingError.failedToCastFields + } + formFields.append(.uploadbox(content, extensions)) + + case "title": + formFields.append(.title(content.example)) + + default: + throw ParsingError.failedToCastFields + } + } + return formFields + } +} diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index 5f60ad25..a33c8b00 100644 --- a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift @@ -34,7 +34,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 } @@ -51,11 +52,12 @@ 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 ) } - 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 } @@ -65,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/Parsers/WriteFormParser.swift b/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift deleted file mode 100644 index 58f2cc43..00000000 --- a/Modules/Sources/ParsingClient/Parsers/WriteFormParser.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// WriteFormParser.swift -// ForPDA -// -// Created by Xialtal on 14.03.25. -// - -import Foundation -import Models - -public struct WriteFormParser { - - public static func parse(from string: String) throws(ParsingError) -> [WriteFormFieldType] { - 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 fields = array[safe: 2] as? [[Any]] else { - throw ParsingError.failedToCastFields - } - - return try parseFormFields(fields) - } - - private static func parseFormFields(_ fieldsRaw: [[Any]]) throws(ParsingError)-> [WriteFormFieldType] { - var formFields: [WriteFormFieldType] = [] - for field in fieldsRaw { - guard let type = field[safe: 0] as? String, - let name = field[safe: 1] as? String, - let description = field[safe: 2] as? String, - let example = field[safe: 3] as? String, - let flag = field[safe: 4] as? Int, - let defaultValue = field[safe: 5] as? String else { - throw ParsingError.failedToCastFields - } - - let content = WriteFormFieldType.FormField( - name: name, - description: description, - example: example, - flag: flag, - defaultValue: defaultValue - ) - - switch type { - case "text", "editor": - formFields.append(type == "text" ? .text(content) : .editor(content)) - - case "dropdown", "checkbox_list": - guard let options = field[6] as? [String] else { - throw ParsingError.failedToCastFields - } - formFields.append(type == "dropdown" ? .dropdown(content, options) : .checkboxList(content, options)) - - case "upload_box": - guard let extensions = field[7] as? [String] else { - throw ParsingError.failedToCastFields - } - formFields.append(.uploadbox(content, extensions)) - - case "title": - formFields.append(.title(content.example)) - - default: - throw ParsingError.failedToCastFields - } - } - return formFields - } -} diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 2b9702a6..398f696a 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -39,15 +39,17 @@ 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 -> PreviewResponse + public var parseTemplateSend: @Sendable (_ response: String) async throws -> TemplateSend // Search public var parseSearch: @Sendable (_ response: String) async throws -> SearchResponse 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 @@ -121,6 +123,12 @@ extension ParsingClient: DependencyKey { }, parsePostSendResponse: { response in return try TopicParser.parsePostSendResponse(from: response) + }, + parseTemplatePreview: { response in + return try FormParser.parseTemplatePreview(from: response) + }, + parseTemplateSend: { response in + return try FormParser.parseTemplateSend(from: response) }, parseSearch: { response in return try SearchParser.parse(from: response) @@ -129,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) diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift index d5780430..5d9411e7 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,12 @@ public struct EditFeature: Reducer, Sendable { @Presents public var destination: Destination.State? @Presents public var alert: AlertState? + public var bbPanel = BBPanelFeature.State(for: .profile) + let user: User var draftUser: User var focus: Field? + var fieldRange: NSRange? var isSending = false var isAvatarUploading = false @@ -78,6 +82,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 +131,52 @@ 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 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 case .alert(.presented(.deleteAvatar)): diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index 30c6d10f..cb0ecec8 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 { @@ -49,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 { @@ -93,9 +104,6 @@ public struct EditScreen: View { } } .bind($store.focus, to: $focus) - .onTapGesture { - focus = nil - } .onAppear { send(.onAppear) } @@ -346,25 +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 { - ForField( + SharedUI.Field( content: content, placeholder: LocalizedStringResource("Input...", bundle: .module), focusEqual: focusEqual, focus: $focus, - characterLimit: characterLimit + 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 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..dcbe2487 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 @@ -39,11 +39,11 @@ public struct ReputationChangeView: View { Section { Field( - text: $store.changeReason.sending(\.reasonChanged), - description: "", - guideText: "", - isEditor: true, - isFocused: $isFocused + 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" : { diff --git a/Modules/Sources/ReputationFeature/ReputationFeature.swift b/Modules/Sources/ReputationFeature/ReputationFeature.swift index ac532b10..77953ec6 100644 --- a/Modules/Sources/ReputationFeature/ReputationFeature.swift +++ b/Modules/Sources/ReputationFeature/ReputationFeature.swift @@ -5,23 +5,31 @@ // Created by Рустам Ойтов on 11.07.2025. // +import Foundation import AnalyticsClient import ComposableArchitecture import APIClient import Models -import WriteFormFeature +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 public enum Destination { case alert(AlertState) - case report(WriteFormFeature) + case report(FormFeature) public enum Alert { case ok } } @@ -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)) @@ -129,8 +143,8 @@ public struct ReputationFeature: Reducer, Sendable { return .send(.delegate(.openProfile(profileId: profileId))) case let .view(.complainButtonTapped(voteId)): - let feature = WriteFormFeature.State( - formFor: .report(id: voteId, type: .reputation) + let feature = FormFeature.State( + type: .report(id: voteId, type: .reputation) ) state.destination = .report(feature) return .none diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index b2294c12..1fbcccdb 100644 --- a/Modules/Sources/ReputationFeature/ReputationScreen.swift +++ b/Modules/Sources/ReputationFeature/ReputationScreen.swift @@ -9,7 +9,7 @@ import SwiftUI import ComposableArchitecture import SharedUI import Models -import WriteFormFeature +import FormFeature @ViewAction(for: ReputationFeature.self) public struct ReputationScreen: View { @@ -51,7 +51,7 @@ public struct ReputationScreen: View { ._toolbarTitleDisplayMode(.inline) .fullScreenCover(item: $store.scope(state: \.destination?.report, action: \.destination.report)) { store in NavigationStack { - WriteFormScreen(store: store) + FormScreen(store: store) } } .onAppear { 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/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/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) + } +} diff --git a/Modules/Sources/SharedUI/Field.swift b/Modules/Sources/SharedUI/Field.swift index 84c79a03..0ab10016 100644 --- a/Modules/Sources/SharedUI/Field.swift +++ b/Modules/Sources/SharedUI/Field.swift @@ -1,77 +1,195 @@ // -// 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 + let bbPanel: () -> BBPanel + + // 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), + @ViewBuilder bbPanel: @escaping () -> BBPanel = { EmptyView() } ) { - 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.bbPanel = bbPanel - 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) + SelectableTextView( + content: content, + selection: selection, + placeholder: placeholder, + characterLimit: characterLimit + ) + + if BBPanel.self != EmptyView.self { + Spacer() } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) - .fill(Color(.Background.teritary)) - .onTapGesture { - isFocused = true - } + + bbPanel() + } + .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 f73cedcc..00000000 --- a/Modules/Sources/SharedUI/ForField.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ForField.swift -// ForPDA -// -// Created by Xialtal on 25.11.25. -// - -import SwiftUI - -public struct ForField: View { - @Environment(\.tintColor) private var tintColor - @FocusState.Binding var focus: T? - - var content: Binding - let placeholder: LocalizedStringResource - let focusEqual: T - let characterLimit: Int? - - public init( - content: Binding, - placeholder: LocalizedStringResource, - focusEqual: T, - focus: FocusState.Binding, - characterLimit: Int? = nil - ) { - self.content = content - self.placeholder = placeholder - self.focusEqual = focusEqual - self.characterLimit = characterLimit - - self._focus = focus - } - - 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) - } - .padding(.vertical, 15) - .padding(.horizontal, 12) - .background { - if #available(iOS 26, *) { - ConcentricRectangle() - .fill(Color(.Background.teritary)) - } else { - RoundedRectangle(cornerRadius: 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) - } - } - } -} diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index 55447bfd..2ea9754e 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/SharedUI/Topic/TopicView.swift b/Modules/Sources/SharedUI/Topic/TopicView.swift index f958fc1a..f076fd97 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 { diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index d1ea34b8..7ed9a1d9 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -90,13 +90,12 @@ extension TopicFeature { analytics.log(TopicEvent.menuSetFavorite) case .writePost: analytics.log(TopicEvent.menuWritePost) + case .writePostWithTemplate: + analytics.log(TopicEvent.menuWritePostWithTemplate) } case let .view(.textQuoted(post, _)): analytics.log(TopicEvent.textQuoted(post.id)) - - case .view(.editWarningSheetCloseButtonTapped): - analytics.log(TopicEvent.editWarningSheetClosed) case .internal(.loadTopic): break 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/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 7870a568..07fd08a5 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" : { @@ -241,52 +221,52 @@ } } }, - "Show results" : { + "Report sent" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Результаты" + "value" : "Жалоба отправлена" } } } }, - "The post has been deleted, showing the nearest one" : { + "Show results" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Пост был удален, показываю ближайший" + "value" : "Результаты" } } } }, - "Today, %@" : { + "The post has been deleted, showing the nearest one" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Сегодня, %@" + "value" : "Пост был удален, показываю ближайший" } } } }, - "Topic Hat" : { + "Today, %@" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Шапка темы" + "value" : "Сегодня, %@" } } } }, - "Understood" : { + "Topic Hat" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Понятно" + "value" : "Шапка темы" } } } diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 08c4cf95..724c5d2f 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -15,7 +15,7 @@ import SharedUI import PersistenceKeys import ParsingClient import PasteboardClient -import WriteFormFeature +import FormFeature import ReputationChangeFeature import TCAExtensions import AnalyticsClient @@ -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) @@ -48,8 +49,7 @@ public struct TopicFeature: Reducer, Sendable { case gallery([URL], [Int], Int) @ReducerCaseIgnored case karmaChange(Int) - case editWarning - case writeForm(WriteFormFeature) + case form(FormFeature) case changeReputation(ReputationChangeFeature) case alert(AlertState) @@ -139,7 +139,6 @@ public struct TopicFeature: Reducer, Sendable { case textQuoted(UIPost, String) case contextMenu(TopicContextMenuAction) case contextPostMenu(PostMenuAction) - case editWarningSheetCloseButtonTapped } case `internal`(Internal) @@ -207,12 +206,13 @@ 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 { - return jumpTo(.post(id: post.id), true, &state) + 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)) } - return .none case let .destination(.presented(.alert(.deletePost(id)))): return .run { send in @@ -283,14 +283,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 formState = FormFeature.State( + type: .post( + type: .new, + topicId: topic.id, + content: .template([]) + ) + ) + state.destination = .form(formState) return .none case .openInBrowser: @@ -322,37 +333,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( + case let .edit(post): + let formState = FormFeature.State( + type: .post( type: .edit(postId: post.id), topicId: state.topicId, - content: .simple(post.content, post.attachments.map { $0.id }) + content: .simple(post.content, post.attachments.map { + .init(id: $0.id, name: $0.name, type: $0.type) + }) ) ) - if post.attachments.isEmpty { - state.destination = .writeForm(feature) - } else { - state.destination = .editWarning - } + state.destination = .form(formState) return .none - case .report(let id): - let feature = WriteFormFeature.State( - formFor: .report(id: id, type: .post) - ) - state.destination = .writeForm(feature) + case let .report(id): + let feature = FormFeature.State(type: .report(id: id, type: .post)) + state.destination = .form(feature) return .none case .delete(let id): @@ -423,24 +430,20 @@ 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): 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 7612404f..85a3e786 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 @@ -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)) + } + } } } @@ -430,9 +436,9 @@ struct NavigationModifier: ViewModifier { func body(content: Content) -> some View { WithPerceptionTracking { content - .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 @@ -460,96 +466,8 @@ 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) - } } } @@ -628,9 +546,9 @@ private extension TopicPostsFilter { initialState: TopicFeature.State( topicId: 0, topicName: "Test Topic", - destination: .writeForm( - WriteFormFeature.State( - formFor: .post( + destination: .form( + FormFeature.State( + type: .post( type: .new, topicId: 0, content: .simple("Test Text", []) ) ) @@ -663,9 +581,9 @@ private extension TopicPostsFilter { initialState: TopicFeature.State( topicId: 0, topicName: "Test Topic", - destination: .writeForm( - WriteFormFeature.State( - formFor: .post( + destination: .form( + FormFeature.State( + type: .post( type: .new, topicId: 0, content: .simple("Test Text", []) ) ) @@ -682,3 +600,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/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 new file mode 100644 index 00000000..69e9612b --- /dev/null +++ b/Modules/Sources/UploadBoxFeature/Models/UploadBoxFile.swift @@ -0,0 +1,73 @@ +// +// 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 var 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(url: URL, ext: String?) + } + + public enum UploadErrorType: Sendable, Equatable { + case sizeTooBig + case badExtension + case uploadFailure + + case noAccessToSSR + case emptyFileData + case other(NSError) + } + + public init( + name: String, + type: FileType, + md5: String? = nil, + isUploading: Bool = false, + uploadingError: UploadErrorType? = nil, + serverId: Int? = nil, + fileSource: FileSource? = nil + ) { + self.name = name + self.type = type + self.md5 = md5 + self.isUploading = isUploading + self.uploadingError = uploadingError + self.serverId = serverId + self.fileSource = fileSource + } +} + +extension UploadBoxFile { + static let mockImage = UploadBoxFile( + name: UUID().uuidString, + type: .image, + md5: UUID().uuidString, + serverId: 0 + ) + + static let mockFile = UploadBoxFile( + name: UUID().uuidString, + type: .file, + md5: UUID().uuidString, + serverId: 1 + ) +} diff --git a/Modules/Sources/UploadBoxFeature/Models/UploadBoxType.swift b/Modules/Sources/UploadBoxFeature/Models/UploadBoxType.swift new file mode 100644 index 00000000..8e766732 --- /dev/null +++ b/Modules/Sources/UploadBoxFeature/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/UploadBoxFeature/Resources/Localizable.xcstrings b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..065a21cf --- /dev/null +++ b/Modules/Sources/UploadBoxFeature/Resources/Localizable.xcstrings @@ -0,0 +1,176 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "An error occurred while uploading the file" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Во время загрузки файла произошла ошибка" + } + } + } + }, + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "Choose from Files" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из файлов" + } + } + } + }, + "Choose from Gallery" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать из галереи" + } + } + } + }, + "Delete" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + } + } + }, + "File import failed. Please, try again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка получения файлов. Пожалуйста, попробуйте снова" + } + } + } + }, + "File size too big" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слишком большой размер" + } + } + } + }, + "Incorrect size" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Некорректный размер" + } + } + } + }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите другой файл. Если в очереди уже есть файлы, то он будет загружен последним" + } + } + } + }, + "Select files..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите файлы…" + } + } + } + }, + "Sorry, this format is not supported" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Извините, данный формат не поддерживается" + } + } + } + }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранный файл будет вставлен в текст, в виде кода, где у вас находится курсор. Или добавится автоматически в конец поста." + } + } + } + }, + "Try Again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробовать еще раз" + } + } + } + }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы можете попробовать загрузить его еще раз. Если в очереди уже есть файлы, то он будет загружен последним" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift new file mode 100644 index 00000000..0a5218cd --- /dev/null +++ b/Modules/Sources/UploadBoxFeature/UploadBoxFeature.swift @@ -0,0 +1,503 @@ +// +// UploadBoxFeature.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import SwiftUI +import ComposableArchitecture +import APIClient + +@Reducer +public struct UploadBoxFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Helpers + + var isPreview: Bool { + 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 + public enum Destination { + 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 { + case gallery, files + } + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents public var destination: Destination.State? + + let type: UploadBoxType + public var allowedExtensions: [String] + public var files: [UploadBoxFile] + + var uploadQueue: [UploadBoxFile.FileSource] = [] + 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 fileButtonTapped(_ serverId: Int, _ name: String) + case fileWithErrorButtonTapped(_ id: UUID) + case selectFilesButtonTapped + case removeFileButtonTapped(UploadBoxFile) + case photosPickerPhotosSelected([UploadBoxFile.FileSource]) + case fileImporterURLsRecieved([URL]) + case fileImporterURLsRecievingFailure + + case fileUploadCanceled(UUID?, UploadBoxFile.UploadErrorType) + } + + case `internal`(Internal) + public enum Internal { + case startNextUpload + case uploadFile(UploadBoxFile, URL) + case uploadFileFinished(index: Int, Int) + case updateFileUploadStatus(UUID, UploadProgressStatus) + } + + case delegate(Delegate) + public enum Delegate { + case someFileUploading + case allFilesAreUploaded + case fileHasBeenRemoved(Int) + case fileHasBeenUploaded(Int) + + case fileHasBeenTapped(Int, String) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + + // MARK: - Cancellable + + private enum CancelID: Hashable { case uploading } + + // 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(.photosPickerPhotosSelected( + [.image(url: URL(string: "")!, 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 .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 .run { [oldFile = state.files[oldIndex]] send in + await send(.view(.removeFileButtonTapped(oldFile))) + await 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)))): + 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 .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: + break + + 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 }), + let error = state.files[index].uploadingError { + return .send(.view(.fileUploadCanceled(id, error))) + } + + 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 .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 { // file already uploaded + return .send(.delegate(.fileHasBeenRemoved(serverId))) + } + + case let .view(.photosPickerPhotosSelected(images)): + if isPreview { + state.files.append(.mockImage) + return .send(.delegate(.fileHasBeenUploaded(0))) + } + if !images.isEmpty { + state.uploadQueue.append(contentsOf: images) + return .send(.internal(.startNextUpload)) + } + + case let .view(.fileImporterURLsRecieved(urls)): + if isPreview { + state.files.append(.mockFile) + return .send(.delegate(.fileHasBeenUploaded(0))) + } + state.uploadQueue.append(contentsOf: urls.map { .file(url: $0) }) + return .send(.internal(.startNextUpload)) + + case .view(.fileImporterURLsRecievingFailure): + state.destination = .alert(.fileImportFailed) + + case let .view(.fileUploadCanceled(id, reason)): + switch reason { + case .sizeTooBig: + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + title: TextState(Localization.fileSizeTooBig), + message: TextState(Localization.selectAnotherFile) + )) + case .badExtension: + state.destination = .alert(.criticalFileConfirmation( + fileId: id, + 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): + guard let item = state.uploadQueue.first else { + state.isAnyFileUploading = false + return .send(.delegate(.allFilesAreUploaded)) + } + + state.isAnyFileUploading = true + state.uploadQueue.removeFirst() + + let url: URL + let name: String + let uploadType: UploadBoxFile.FileType + let fileExtension: String? + + switch item { + case .file(let u): + url = u + name = url.lastPathComponent + uploadType = .file + fileExtension = url.pathExtension + case .image(let u, let ext): + url = u + uploadType = .image + fileExtension = ext + name = "\(UUID().uuidString).\(fileExtension ?? "bin")" + } + + 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)) + } + } + + let file = UploadBoxFile( + name: name, + type: uploadType, + isUploading: true, + fileSource: item + ) + state.files.append(file) + + return .send(.internal(.uploadFile(file, url))) + + case let .internal(.uploadFile(file, url)): + state.isAnyFileUploading = true + + return .run(priority: .userInitiated, name: file.name) { send in + if file.type == .file { + guard url.startAccessingSecurityScopedResource() else { + await send(.view(.fileUploadCanceled(file.id, .noAccessToSSR))) + await send(.internal(.startNextUpload)) + return + } + } + + let data = try? Data(contentsOf: url) + + if file.type == .file { + url.stopAccessingSecurityScopedResource() + } + + guard let data else { + await send(.view(.fileUploadCanceled(file.id, .emptyFileData))) + await send(.internal(.startNextUpload)) + return + } + + let request = UploadRequest(fileName: file.name, fileData: data, isQms: false) + + await send(.delegate(.someFileUploading)) + + for await status in apiClient.upload(request) { + await send(.internal(.updateFileUploadStatus(file.id, status))) + } + } + .cancellable(id: CancelID.uploading) + + case let .internal(.updateFileUploadStatus(id, status)): + 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 { + 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))) + + 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 + + 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 + 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)) + } + + case let .internal(.uploadFileFinished(index, responseFileId)): + state.files[index].serverId = responseFileId + state.files[index].fileSource = nil + state.files[index].isUploading = false + return .run { send in + await send(.delegate(.fileHasBeenUploaded(responseFileId))) + await send(.internal(.startNextUpload)) + } + } + + return .none + } + .ifLet(\.$destination, action: \.destination) + } +} + +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", bundle: .module) + } + ) + } + + 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 } + ) + } + + nonisolated(unsafe) static let fileImportFailed = AlertState { + 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") + } + } +} + +// 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 + } +} diff --git a/Modules/Sources/UploadBoxFeature/UploadBoxView.swift b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift new file mode 100644 index 00000000..43bd690d --- /dev/null +++ b/Modules/Sources/UploadBoxFeature/UploadBoxView.swift @@ -0,0 +1,284 @@ +// +// UploadBoxView.swift +// ForPDA +// +// Created by Xialtal on 2.01.26. +// + +import SwiftUI +import ComposableArchitecture +import PhotosUI + +@ViewAction(for: UploadBoxFeature.self) +public struct UploadBoxView: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @State private var pickerItems: [PhotosPickerItem] = [] + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + WithPerceptionTracking { + VStack(spacing: 6) { + WithPerceptionTracking { + switch store.type { + case .bbPanel: + FilesGrid() + case .form: + if store.files.isEmpty { + FormUploadView() + } else { + FilesGrid() + } + } + } + } + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .confirmationDialog( + $store.scope( + state: \.destination?.confirmationDialog, + action: \.destination.confirmationDialog + ) + ) + .fileImporter( + isPresented: Binding($store.destination.fileImporter), + allowedContentTypes: [.item], + allowsMultipleSelection: true, + onCompletion: { result in + switch result { + case let .success(urls): + send(.fileImporterURLsRecieved(urls)) + case let .failure(error): + if let error = error as? CocoaError, error.code == .userCancelled { + // canceled by user + } else { + send(.fileImporterURLsRecievingFailure) + } + } + } + ) + .photosPicker( + isPresented: Binding($store.destination.photosPicker), + selection: $pickerItems, + maxSelectionCount: 10 + ) + .task(id: pickerItems) { + var photos: [UploadBoxFile.FileSource] = [] + for item in pickerItems { + if let media = try? await item.loadTransferable(type: PhotosPickerMedia.self) { + let type = item.supportedContentTypes.first + photos.append(.image(url: media.url, ext: type?.preferredFilenameExtension)) + } + } + pickerItems = [] + send(.photosPickerPhotosSelected(photos)) + } + .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.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) + .foregroundStyle(Color(.Labels.primary)) + .lineLimit(2) + .multilineTextAlignment(.center) + } + } + .onTapGesture { + if let serverId = file.serverId, file.uploadingError == nil { + send(.fileButtonTapped(serverId, file.name)) + } else { + send(.fileWithErrorButtonTapped(file.id)) + } + } + .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: [.mockFile, .mockImage, .mockFile] + ) + ) { + 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: [.mockFile, .mockImage, .mockFile] + ) + ) { + UploadBoxFeature() + } + ) + .padding(.horizontal, 16) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift b/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift deleted file mode 100644 index 102e14ac..00000000 --- a/Modules/Sources/WriteFormFeature/Preview/FormPreviewFeature.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// FormPreviewFeature.swift -// ForPDA -// -// Created by Xialtal on 16.03.25. -// - -import Foundation -import ComposableArchitecture -import APIClient -import Models -import TopicBuilder -import SharedUI - -@Reducer -public struct FormPreviewFeature: Reducer, Sendable { - - public init() {} - - // MARK: - State - - @ObservableState - public struct State: Equatable { - public let formType: WriteFormForType - - var contentTypes: [UITopicType] = [] - - var isPreviewLoading = false - - public init( - formType: WriteFormForType - ) { - self.formType = formType - } - } - - // MARK: - Action - - public enum Action { - case onAppear - - case cancelButtonTapped - - case _loadSimplePreview(id: Int, content: String, attIds: [Int]) - case _simplePreviewResponse(Result) - } - - // 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: - if case let .post(_, topicId, contentType) = state.formType { - 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 - } - } - return .none - - case .cancelButtonTapped: - return .run { _ in await dismiss() } - - case let ._loadSimplePreview(id, content, attachments): - state.isPreviewLoading = true - return .run { [ - topicId = id, - content = content, - attachments = attachments - ] 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, - content: content, - flag: 0, - attachments: attachments - ) - ) - )} - await send(._simplePreviewResponse(result)) - } catch: { error, send in - await send(._simplePreviewResponse(.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) - } - } - } -} diff --git a/Modules/Sources/WriteFormFeature/Preview/FormPreviewView.swift b/Modules/Sources/WriteFormFeature/Preview/FormPreviewView.swift deleted file mode 100644 index 86454f88..00000000 --- a/Modules/Sources/WriteFormFeature/Preview/FormPreviewView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// FormPreviewView.swift -// ForPDA -// -// Created by Xialtal on 16.03.25. -// - -import SwiftUI -import ComposableArchitecture -import SharedUI -import Models -import TopicBuilder - -struct FormPreviewView: View { - - @Perception.Bindable var store: StoreOf - - @Environment(\.tintColor) private var tintColor - - init(store: StoreOf) { - self.store = store - } - - 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: []) { _ 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) - } - } - .padding(16) - ._toolbarTitleDisplayMode(.inline) - .navigationTitle(Text("Preview", bundle: .module)) - } - .background(Color(.Background.primary)) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - store.send(.cancelButtonTapped) - } label: { - Text("Cancel", bundle: .module) - } - } - } - .overlay { - if store.isPreviewLoading && store.contentTypes.isEmpty { - PDALoader() - .frame(width: 24, height: 24) - } - } - .onAppear { - store.send(.onAppear) - } - } - } -} diff --git a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift b/Modules/Sources/WriteFormFeature/WriteFormFeature.swift deleted file mode 100644 index d13298bf..00000000 --- a/Modules/Sources/WriteFormFeature/WriteFormFeature.swift +++ /dev/null @@ -1,371 +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 - 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 textContent = "" - 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 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 updateFieldContent(Int, String) - 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 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.textContent = content - let field: WriteFormFieldType = .editor(.init( - name: "", - description: "", - example: "", - flag: 0, - defaultValue: state.inPostEditingMode ? content : "" - )) - return .send(.internal(.formResponse(.success([field])))) - - 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])))) - } - - case .view(.publishButtonTapped): - state.isPublishing = true - 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, []) - ) - ) - ) - return .none - - case .view(.dismissButtonTapped): - return .run { _ in await dismiss() } - - case .view(.updateFieldContent(_, let content)): - state.textContent = content - return .none - - case let .internal(.publishPost(flag: postTypeFlag)): - switch state.formFor { - 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: request) } - await send(.internal(.simplePostResponse(result))) - - case .edit(postId: 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: request) } - await send(.internal(.simplePostResponse(result))) - } - } - - case .report(let id, let type): - return .run { [id = id, type = type, 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(.internal(.reportResponse(result))) - } - - default: - return .none - } - - case let .internal(.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 - ) } - 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 - - 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 = 1 - case .doNotAttach: - editorFlag = 3 - 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(.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)): - if case let .report(status) = result { - // Not closing form if error. - if status.isError { - return .none - } - } - 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) - - Analytics() - } -} - -extension WriteFormFeature.Destination.State: Equatable {} - -// 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") - } - } -} diff --git a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift b/Modules/Sources/WriteFormFeature/WriteFormScreen.swift deleted file mode 100644 index eabd48d4..00000000 --- a/Modules/Sources/WriteFormFeature/WriteFormScreen.swift +++ /dev/null @@ -1,247 +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)) - ._toolbarTitleDisplayMode(.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.textContent.isEmptyAfterTrimming()) - .disabled(store.isPublishing) - } - } - .onAppear { - send(.onAppear) - } - } - } - - @ViewBuilder - private func WriteForm() -> some View { - ScrollView { - VStack { - ForEach(store.formFields.indices, id: \.self) { index in - WithPerceptionTracking { - VStack { - WriteFormView( - type: store.formFields[index], - isFocused: $isFocused, - onUpdateContent: { content in - if content != nil { - send(.updateFieldContent(index, content!)) - } - return store.textContent - } - ) - } - .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.textContent.isEmptyAfterTrimming()) - .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 e5c456b8..00000000 --- a/Modules/Sources/WriteFormFeature/WriteFormView.swift +++ /dev/null @@ -1,383 +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: (String?) -> String // (String) -> Void?, - var onUpdateSelection: ((Int, String, Bool) -> Void)? - - var body: some View { - switch type { - case .text(let content): - Section { - Field( - text: Binding( - get: { onUpdateContent(nil) }, - set: { _ = onUpdateContent($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: { onUpdateContent(nil) }, - set: { _ = onUpdateContent($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, id: \.self) { option in - // TODO: Implement Button - Button { - // callback - } label: { Text(option) } - } - } label: { - HStack { - Text(options[0]) // FIXME: Fix. - .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: \.self) { index in - Toggle(isOn: Binding( - // FIXME: Now all checkboxes always false. Find the solution with getter. - get: { false }, - set: { isSelected in - onUpdateSelection?(index, options[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) - } - - 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) - } - } - } - } - - @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: - 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 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( - 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 "" }) - - 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 "" }) - - 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( - 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 "" }) - - 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( - name: "Device type", - description: "Select device type.", - example: "Example: Phone", - flag: 1, - defaultValue: "" - ), ["Phone", "SmartWatch"]), isFocused: $isFocused, onUpdateContent: { _ in "" }) - - 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( - name: "", - description: "", - example: "", - flag: 1, - defaultValue: "" - ), ["I accept all"]), isFocused: $isFocused, onUpdateContent: { _ in "" }) - - 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( - name: "Device photos", - description: "Upload device photos. Allowed formats JPG, GIF, PNG", - example: "", - flag: 1, - defaultValue: "" - ), ["jpg", "gif", "png"]), isFocused: $isFocused, onUpdateContent: { _ in "" }) - - Color.white - } - .padding(.horizontal, 16) -} diff --git a/Project.swift b/Project.swift index 6e295e79..b6b8e6e7 100644 --- a/Project.swift +++ b/Project.swift @@ -106,7 +106,7 @@ let project = Project( .Internal.SharedUI, .Internal.TCAExtensions, .Internal.ToastClient, - .Internal.WriteFormFeature, + .Internal.FormFeature, .SPM.NukeUI, .SPM.SFSafeSymbols, .SPM.SkeletonUI, @@ -149,6 +149,18 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "BBPanelFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.SharedUI, + .Internal.UploadBoxFeature, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "BookmarksFeature", @@ -236,6 +248,7 @@ let project = Project( .Internal.SharedUI, .Internal.TCAExtensions, .Internal.ToastClient, + .Internal.FormFeature, .SPM.NukeUI, .SPM.SFSafeSymbols, .SPM.TCA, @@ -317,6 +330,7 @@ let project = Project( .Internal.AnalyticsClient, .Internal.APIClient, .Internal.BBBuilder, + .Internal.BBPanelFeature, .Internal.Models, .Internal.NotificationsClient, .Internal.ParsingClient, @@ -396,7 +410,8 @@ let project = Project( .Internal.APIClient, .Internal.Models, .Internal.SharedUI, - .Internal.WriteFormFeature, + .Internal.FormFeature, + .Internal.ToastClient, .SPM.TCA, ] ), @@ -471,7 +486,7 @@ let project = Project( .Internal.TCAExtensions, .Internal.ToastClient, .Internal.TopicBuilder, - .Internal.WriteFormFeature, + .Internal.FormFeature, .SPM.MemberwiseInit, .SPM.NukeUI, .SPM.RichTextKit, @@ -479,16 +494,29 @@ let project = Project( .SPM.TCA, ] ), - + .feature( - name: "WriteFormFeature", + name: "UploadBoxFeature", dependencies: [ .Internal.AnalyticsClient, .Internal.APIClient, .Internal.BBBuilder, .Internal.Models, + .SPM.TCA, + ] + ), + + .feature( + name: "FormFeature", + hasTests: true, + dependencies: [ + .Internal.APIClient, + .Internal.BBPanelFeature, + .Internal.Models, + .Internal.ParsingClient, .Internal.SharedUI, .Internal.TopicBuilder, + .Internal.UploadBoxFeature, .SPM.NukeUI, .SPM.RichTextKit, .SPM.TCA, @@ -683,6 +711,16 @@ let project = Project( ] ), + .tests( + name: "FormFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.FormFeature, + .SPM.TCA + ] + ), + // MARK: - Extensions - .target( @@ -973,11 +1011,13 @@ 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") 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") @@ -995,7 +1035,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 WriteFormFeature = TargetDependency.target(name: "WriteFormFeature") + static let UploadBoxFeature = TargetDependency.target(name: "UploadBoxFeature") // Clients static let AnalyticsClient = TargetDependency.target(name: "AnalyticsClient")