also added copies for the section headers, and fixes an issue where the move action voice over was not reading the option
236 lines
9.1 KiB
Swift
236 lines
9.1 KiB
Swift
//
|
|
// Copyright 2022-2024 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 {
|
|
@ObservedObject 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<PollFormScreenViewStateBindings.Option>.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 = PollFormScreenViewModel(mode: .new)
|
|
static let editViewModel = PollFormScreenViewModel(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 {
|
|
NavigationStack {
|
|
PollFormScreen(context: viewModel.context)
|
|
}
|
|
.previewDisplayName("New")
|
|
|
|
NavigationStack {
|
|
PollFormScreen(context: editViewModel.context)
|
|
}
|
|
.previewDisplayName("Edit")
|
|
}
|
|
}
|
|
|
|
private extension Binding where Value == String {
|
|
func limited(to limit: Int) -> Self {
|
|
.init {
|
|
wrappedValue
|
|
} set: { newValue in
|
|
wrappedValue = String(newValue.prefix(limit))
|
|
}
|
|
}
|
|
}
|