// // Copyright 2025 Element Creations Ltd. // Copyright 2022-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Compound import SwiftUI struct PollFormScreen: View { @Bindable var context: PollFormScreenViewModel.Context @FocusState var focus: Focus? enum Focus: Hashable { case question case option(index: Int) } var body: some View { Form { questionSection optionsSection showResultsSection deletePollSection } .trackAnalyticsIfNeeded(context: context) .compoundList() .scrollDismissesKeyboard(.immediately) .environment(\.editMode, .constant(.active)) .navigationTitle(context.viewState.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .animation(.elementDefault, value: context.options) .interactiveDismissDisabled(context.viewState.formContentHasChanged) .alert(item: $context.alertInfo) } // MARK: - Private private var questionSection: some View { Section { ListRow(label: .plain(title: L10n.screenCreatePollQuestionHint), kind: .textField(text: $context.question, axis: .vertical)) .focused($focus, equals: .question) .accessibilityIdentifier(A11yIdentifiers.pollFormScreen.question) } header: { Text(L10n.screenCreatePollQuestionDesc) .compoundListSectionHeader() } } private var optionsSection: some View { Section { ForEach(context.options) { option in if let index = context.options.firstIndex(of: option) { PollFormOptionRow(text: $context.options[index].text.limited(to: 240), placeholder: L10n.screenCreatePollAnswerHint(index + 1), canDeleteItem: context.options.count > 2) { if case .option(let focusedIndex) = focus, focusedIndex == index { focus = nil } context.send(viewAction: .deleteOption(index: index)) } .focused($focus, equals: .option(index: index)) .accessibilityIdentifier(A11yIdentifiers.pollFormScreen.optionID(index)) .onChange(of: context.options[index].text) { _, newOptionText in guard let lastCharacter = newOptionText.last, lastCharacter.isNewline else { return } context.options[index].text.removeLast() submitOption(at: index) } .onSubmit { // onSubmit is still called when using the return key on a hardware keyboard submitOption(at: index) } .submitLabel(index == context.options.endIndex - 1 ? .done : .next) } } .onMove { offsets, toOffset in context.options.move(fromOffsets: offsets, toOffset: toOffset) } if context.options.count < context.viewState.maxNumberOfOptions { ListRow(label: .plain(title: L10n.screenCreatePollAddOptionBtn), kind: .button { context.send(viewAction: .addOption) focus = context.options.indices.last.map { .option(index: $0) } }) .accessibilityIdentifier(A11yIdentifiers.pollFormScreen.addOption) } } header: { Text(L10n.screenCreatePollOptionsSectionTitle) .compoundListSectionHeader() } // Disables animations when the text view resizes for multiline .animation(.noAnimation, value: UUID()) } private func submitOption(at index: Array.Index) { let nextOptionIndex = index == context.options.endIndex - 1 ? nil : index + 1 focus = nextOptionIndex.map { .option(index: $0) } } private var showResultsSection: some View { Section { ListRow(label: .plain(title: L10n.screenCreatePollAnonymousDesc), kind: .toggle($context.isUndisclosed)) .accessibilityIdentifier(A11yIdentifiers.pollFormScreen.pollKind) } header: { Text(L10n.screenCreatePollSettingsSectionTitle) .compoundListSectionHeader() } } @ViewBuilder private var deletePollSection: some View { switch context.viewState.mode { case .edit: Section { ListRow(label: .plain(title: L10n.actionDeletePoll, role: .destructive), kind: .button { context.send(viewAction: .delete) }) } case .new: EmptyView() } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { Button(L10n.actionCancel) { context.send(viewAction: .cancel) } } ToolbarItem(placement: .confirmationAction) { Button(context.viewState.submitButtonTitle) { context.send(viewAction: .submit) } .disabled(context.viewState.isSubmitButtonDisabled) .accessibilityIdentifier(A11yIdentifiers.pollFormScreen.submit) } } } private extension View { @MainActor @ViewBuilder func trackAnalyticsIfNeeded(context: PollFormScreenViewModel.Context) -> some View { switch context.viewState.mode { case .edit: self case .new: track(screen: .CreatePollView) } } } private struct PollFormOptionRow: View { @Environment(\.editMode) var editMode @Binding var text: String let placeholder: String let canDeleteItem: Bool let deleteAction: () -> Void var body: some View { ListRow(kind: .custom { HStack(spacing: 16) { if editMode?.wrappedValue == .active { Button(role: .destructive, action: deleteAction) { CompoundIcon(\.delete) } .disabled(!canDeleteItem) .buttonStyle(.compound(.textLink)) .accessibilityLabel(L10n.screenCreatePollRemoveAccessibilityLabel(L10n.screenCreatePollOptionAccessibilityLabel(placeholder, text))) } TextField(text: $text, axis: .vertical) { Text(placeholder) .compoundTextFieldPlaceholder() } // For some reason the placeholder is always read by voice over even if I disable or override the label, so if the text is empty we use an empy accessibility label .accessibilityLabel(text.isEmpty ? "" : L10n.screenCreatePollOptionAccessibilityLabel(placeholder, text)) // Allows the move action voice over to give priority to this field over the remove button .accessibilitySortPriority(1) .tint(.compound.iconAccentTertiary) .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } } .padding(.horizontal, ListRowPadding.horizontal) .padding(.vertical, ListRowPadding.vertical) }) } } // MARK: - Previews struct PollFormScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = makeViewModel(mode: .new) static let editViewModel = makeViewModel(mode: .edit(eventID: "1234", poll: poll)) static let poll = Poll(question: "Cats or Dogs?", kind: .disclosed, maxSelections: 1, options: [ .init(id: "0", text: "Cats", votes: 0, allVotes: 0, isSelected: false, isWinning: false), .init(id: "0", text: "Dogs", votes: 0, allVotes: 0, isSelected: false, isWinning: false), .init(id: "0", text: "Fish", votes: 0, allVotes: 0, isSelected: false, isWinning: false) ], votes: [:], endDate: nil, createdByAccountOwner: true) static var previews: some View { ElementNavigationStack { PollFormScreen(context: viewModel.context) } .previewDisplayName("New") ElementNavigationStack { PollFormScreen(context: editViewModel.context) } .previewDisplayName("Edit") } static func makeViewModel(mode: PollFormMode) -> PollFormScreenViewModel { PollFormScreenViewModel(mode: mode, timelineController: MockTimelineController(), analytics: ServiceLocator.shared.analytics, userIndicatorController: UserIndicatorControllerMock()) } } private extension Binding where Value == String { func limited(to limit: Int) -> Self { .init { wrappedValue } set: { newValue in wrappedValue = String(newValue.prefix(limit)) } } }