From 005001b08135ec8f76fd45b6411e07523502e6a5 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 3 May 2021 23:12:59 -0400 Subject: [PATCH] Add authoring polls Closes #48 --- Pachyderm/Client.swift | 11 +- Tusker.xcodeproj/project.pbxproj | 12 +- Tusker/Models/Draft.swift | 64 +++++- .../Compose/ComposeAttachmentsList.swift | 31 ++- ...ield.swift => ComposeEmojiTextField.swift} | 42 +++- Tusker/Screens/Compose/ComposePollView.swift | 209 ++++++++++++++++++ Tusker/Screens/Compose/ComposeView.swift | 16 +- 7 files changed, 368 insertions(+), 17 deletions(-) rename Tusker/Screens/Compose/{ComposeContentWarningTextField.swift => ComposeEmojiTextField.swift} (84%) create mode 100644 Tusker/Screens/Compose/ComposePollView.swift diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index 44ffd35c..9d16a895 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -298,7 +298,10 @@ public class Client { sensitive: Bool? = nil, spoilerText: String? = nil, visibility: Status.Visibility? = nil, - language: String? = nil) -> Request { + language: String? = nil, + pollOptions: [String]? = nil, + pollExpiresIn: Int? = nil, + pollMultiple: Bool? = nil) -> Request { return Request(method: .post, path: "/api/v1/statuses", body: ParametersBody([ "status" => text, "content_type" => contentType.mimeType, @@ -306,8 +309,10 @@ public class Client { "sensitive" => sensitive, "spoiler_text" => spoilerText, "visibility" => visibility?.rawValue, - "language" => language - ] + "media_ids" => media?.map { $0.id })) + "language" => language, + "poll[expires_in]" => pollExpiresIn, + "poll[multiple]" => pollMultiple, + ] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions)) } // MARK: - Timelines diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index fbbb13e1..bd5b9bf0 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -160,6 +160,7 @@ D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */; }; D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */; }; + D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D662AEF1263A4BE10082A153 /* ComposePollView.swift */; }; D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */; }; D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */; }; D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; }; @@ -275,7 +276,7 @@ D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; - D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */; }; + D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */; }; D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; @@ -538,6 +539,7 @@ D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = ""; }; D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedTableViewCell.swift; sourceTree = ""; }; D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PollFinishedTableViewCell.xib; sourceTree = ""; }; + D662AEF1263A4BE10082A153 /* ComposePollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposePollView.swift; sourceTree = ""; }; D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConversationMainStatusTableViewCell.xib; sourceTree = ""; }; D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusTableViewCell.swift; sourceTree = ""; }; D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; @@ -648,7 +650,7 @@ D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; - D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContentWarningTextField.swift; sourceTree = ""; }; + D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; }; D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionViewController.swift; sourceTree = ""; }; D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCollectionViewCell.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; @@ -1111,13 +1113,14 @@ D6A57407255C53EC00674551 /* ComposeTextViewCaretScrolling.swift */, D62275A924F1E01C00B82A16 /* ComposeTextView.swift */, D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */, + D662AEF1263A4BE10082A153 /* ComposePollView.swift */, D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */, D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */, D622757724EE133700B82A16 /* ComposeAssetPicker.swift */, D62275A524F1C81800B82A16 /* ComposeReplyView.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */, - D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */, + D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */, D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */, D670F8B52537DC890046588A /* EmojiPickerWrapper.swift */, @@ -1980,6 +1983,7 @@ D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, + D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */, D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, @@ -2041,7 +2045,7 @@ D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */, - D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */, + D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */, D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, diff --git a/Tusker/Models/Draft.swift b/Tusker/Models/Draft.swift index 8bc73ce3..523737bf 100644 --- a/Tusker/Models/Draft.swift +++ b/Tusker/Models/Draft.swift @@ -20,13 +20,15 @@ class Draft: Codable, ObservableObject { @Published var attachments: [CompositionAttachment] @Published var inReplyToID: String? @Published var visibility: Status.Visibility + @Published var poll: Poll? var initialText: String var hasContent: Bool { (!text.isEmpty && text != initialText) || (contentWarningEnabled && !contentWarning.isEmpty) || - attachments.count > 0 + attachments.count > 0 || + poll?.hasContent == true } var textForPosting: String { @@ -46,6 +48,7 @@ class Draft: Codable, ObservableObject { self.attachments = [] self.inReplyToID = nil self.visibility = Preferences.shared.defaultPostVisibility + self.poll = nil self.initialText = "" } @@ -75,6 +78,7 @@ class Draft: Codable, ObservableObject { self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments) self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility) + self.poll = try container.decode(Poll.self, forKey: .poll) self.initialText = try container.decode(String.self, forKey: .initialText) } @@ -92,6 +96,7 @@ class Draft: Codable, ObservableObject { try container.encode(attachments, forKey: .attachments) try container.encode(inReplyToID, forKey: .inReplyToID) try container.encode(visibility, forKey: .visibility) + try container.encode(poll, forKey: .poll) try container.encode(initialText, forKey: .initialText) } @@ -115,11 +120,68 @@ extension Draft { case attachments case inReplyToID case visibility + case poll case initialText } } +extension Draft { + class Poll: Codable, ObservableObject { + @Published var options: [Option] + @Published var multiple: Bool + @Published var duration: TimeInterval + + var hasContent: Bool { + options.contains { !$0.text.isEmpty } + } + + init() { + self.options = [Option(""), Option("")] + self.multiple = false + self.duration = 24 * 60 * 60 // 1 day + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.options = try container.decode([Option].self, forKey: .options) + self.multiple = try container.decode(Bool.self, forKey: .multiple) + self.duration = try container.decode(TimeInterval.self, forKey: .duration) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(options, forKey: .options) + try container.encode(multiple, forKey: .multiple) + try container.encode(duration, forKey: .duration) + } + + private enum CodingKeys: String, CodingKey { + case options + case multiple + case duration + } + + class Option: Identifiable, Codable, ObservableObject { + let id = UUID() + @Published var text: String + + init(_ text: String) { + self.text = text + } + + required init(from decoder: Decoder) throws { + self.text = try decoder.singleValueContainer().decode(String.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(text) + } + } + } +} + extension MastodonController { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft { diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift index 7c015589..c4186712 100644 --- a/Tusker/Screens/Compose/ComposeAttachmentsList.swift +++ b/Tusker/Screens/Compose/ComposeAttachmentsList.swift @@ -60,6 +60,14 @@ struct ComposeAttachmentsList: View { .foregroundColor(.blue) .frame(height: cellHeight / 2) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + + Button(action: self.togglePoll) { + Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") + } + .disabled(!canAddPoll) + .foregroundColor(.blue) + .frame(height: cellHeight / 2) + .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) } .frame(height: totalListHeight) .onAppear(perform: self.didAppear) @@ -84,14 +92,25 @@ struct ComposeAttachmentsList: View { case .pleroma: return true case .mastodon: - return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } + return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil + } + } + + private var canAddPoll: Bool { + switch mastodonController.instance?.instanceType { + case nil: + return false + case .pleroma: + return true + case .mastodon: + return draft.attachments.isEmpty } } private var totalListHeight: CGFloat { let totalRowHeights = rowHeights.values.reduce(0, +) let totalPadding = CGFloat(draft.attachments.count) * cellPadding - let addButtonHeight = cellHeight + cellPadding * 2 + let addButtonHeight = 3 * (cellHeight / 2 + cellPadding) return totalRowHeights + totalPadding + addButtonHeight } @@ -155,6 +174,14 @@ struct ComposeAttachmentsList: View { uiState.composeDrawingMode = .createNew uiState.delegate?.presentComposeDrawing() } + + private func togglePoll() { + UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) + + withAnimation { + draft.poll = draft.poll == nil ? Draft.Poll() : nil + } + } } //struct ComposeAttachmentsList_Previews: PreviewProvider { diff --git a/Tusker/Screens/Compose/ComposeContentWarningTextField.swift b/Tusker/Screens/Compose/ComposeEmojiTextField.swift similarity index 84% rename from Tusker/Screens/Compose/ComposeContentWarningTextField.swift rename to Tusker/Screens/Compose/ComposeEmojiTextField.swift index 309864b6..0c2947c1 100644 --- a/Tusker/Screens/Compose/ComposeContentWarningTextField.swift +++ b/Tusker/Screens/Compose/ComposeEmojiTextField.swift @@ -8,22 +8,50 @@ import SwiftUI -struct ComposeContentWarningTextField: UIViewRepresentable { +struct ComposeEmojiTextField: UIViewRepresentable { typealias UIViewType = UITextField - @Binding var text: String - @EnvironmentObject private var uiState: ComposeUIState + @Binding private var text: String + private let placeholder: String + private var didChange: ((String) -> Void)? + private var didEndEditing: (() -> Void)? + private var backgroundColor: UIColor? = nil + + init(text: Binding, placeholder: String) { + self._text = text + self.placeholder = placeholder + self.didChange = nil + self.didEndEditing = nil + } + + mutating func didChange(_ didChange: @escaping (String) -> Void) -> Self { + self.didChange = didChange + return self + } + + mutating func didEndEditing(_ didEndEditing: @escaping () -> Void) -> Self { + self.didEndEditing = didEndEditing + return self + } + + mutating func backgroundColor(_ color: UIColor) -> Self { + self.backgroundColor = color + return self + } + func makeUIView(context: Context) -> UITextField { let view = UITextField() - view.placeholder = "Write your warning here" + view.placeholder = placeholder view.borderStyle = .roundedRect view.delegate = context.coordinator view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged) + view.backgroundColor = backgroundColor + context.coordinator.textField = view context.coordinator.uiState = uiState context.coordinator.text = $text @@ -37,6 +65,8 @@ struct ComposeContentWarningTextField: UIViewRepresentable { } else { uiView.text = text } + context.coordinator.didChange = didChange + context.coordinator.didEndEditing = didEndEditing } func makeCoordinator() -> Coordinator { @@ -47,11 +77,14 @@ struct ComposeContentWarningTextField: UIViewRepresentable { weak var textField: UITextField? var text: Binding! var uiState: ComposeUIState! + var didChange: ((String) -> Void)? + var didEndEditing: (() -> Void)? var skipSettingTextOnNextUpdate = false @objc func didChange(_ textField: UITextField) { text.wrappedValue = textField.text ?? "" + didChange?(text.wrappedValue) } func textFieldDidBeginEditing(_ textField: UITextField) { @@ -61,6 +94,7 @@ struct ComposeContentWarningTextField: UIViewRepresentable { func textFieldDidEndEditing(_ textField: UITextField) { updateAutocompleteState(textField: textField) + didEndEditing?() } func textFieldDidChangeSelection(_ textField: UITextField) { diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift new file mode 100644 index 00000000..2158a8a6 --- /dev/null +++ b/Tusker/Screens/Compose/ComposePollView.swift @@ -0,0 +1,209 @@ +// +// ComposePollView.swift +// Tusker +// +// Created by Shadowfacts on 4/28/21. +// Copyright © 2021 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ComposePollView: View { + private static let formatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.maximumUnitCount = 1 + f.unitsStyle = .full + f.allowedUnits = [.weekOfMonth, .day, .hour, .minute] + return f + }() + + @ObservedObject var draft: Draft + @ObservedObject var poll: Draft.Poll + + @Environment(\.colorScheme) var colorScheme: ColorScheme + + @State private var duration: Duration { + didSet { + poll.duration = duration.timeInterval + } + } + + init(draft: Draft, poll: Draft.Poll) { + self.draft = draft + self.poll = poll + + self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay) + } + + var body: some View { + VStack { + HStack { + Text("Poll") + .font(.headline) + + Spacer() + + Button(action: self.removePoll) { + Image(systemName: "xmark") + .imageScale(.small) + .padding(4) + } + .accentColor(buttonForegroundColor) + .background(Circle().foregroundColor(buttonBackgroundColor)) + } + + ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in + ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset) + } + .transition(.slide) + + Button(action: self.addOption) { + Label("Add Option", systemImage: "plus") + } + + HStack { + // use .animation(nil) on the binding and .frame(maxWidth: .infinity) on labels so frame doesn't have a size change animation when the text changes + Picker(selection: $poll.multiple.animation(nil), label: Text(poll.multiple ? "Allow multiple choices" : "Single choice").frame(maxWidth: .infinity)) { + Text("Allow multiple choices").tag(true) + Text("Single choice").tag(false) + } + .pickerStyle(MenuPickerStyle()) + .frame(maxWidth: .infinity) + + Picker(selection: $duration.animation(nil), label: Text(verbatim: ComposePollView.formatter.string(from: duration.timeInterval)!).frame(maxWidth: .infinity)) { + ForEach(Duration.allCases, id: \.self) { (duration) in + Text(ComposePollView.formatter.string(from: duration.timeInterval)!).tag(duration) + } + } + .pickerStyle(MenuPickerStyle()) + .frame(maxWidth: .infinity) + } + } + .padding(8) + .background( + backgroundColor + .cornerRadius(10) + ) + } + + private var backgroundColor: Color { + // in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want + colorScheme == .dark ? Color(UIColor.secondarySystemBackground) : Color(white: 0.95) + } + + private var buttonBackgroundColor: Color { + Color(white: colorScheme == .dark ? 0.1 : 0.8) + } + + private var buttonForegroundColor: Color { + Color(UIColor.label) + } + + private func removePoll() { + withAnimation { + self.draft.poll = nil + } + } + + private func addOption() { + withAnimation(.easeInOut(duration: 0.25)) { + poll.options.append(Draft.Poll.Option("")) + } + } +} + +extension ComposePollView { + enum Duration: Hashable, Equatable, CaseIterable { + case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays + + static func fromTimeInterval(_ ti: TimeInterval) -> Duration? { + for it in allCases where it.timeInterval == ti { + return it + } + return nil + } + + var timeInterval: TimeInterval { + switch self { + case .fiveMinutes: + return 5 * 60 + case .thirtyMinutes: + return 30 * 60 + case .oneHour: + return 60 * 60 + case .sixHours: + return 6 * 60 * 60 + case .oneDay: + return 24 * 60 * 60 + case .threeDays: + return 3 * 24 * 60 * 60 + case .sevenDays: + return 7 * 24 * 60 * 60 + } + } + } +} + +struct ComposePollOption: View { + @ObservedObject var poll: Draft.Poll + @ObservedObject var option: Draft.Poll.Option + let optionIndex: Int + + var body: some View { + HStack(spacing: 4) { + Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2) + .animation(.default) + + + textField + + Button(action: self.removeOption) { + Image(systemName: "minus.circle.fill") + } + .foregroundColor(poll.options.count == 1 ? .gray : .red) + .disabled(poll.options.count == 1) + } + } + + private var textField: some View { + var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)") + return field.backgroundColor(.systemBackground) + } + + private func removeOption() { + _ = withAnimation { + poll.options.remove(at: optionIndex) + } + } + + struct Checkbox: View { + private let radiusFraction: CGFloat + private let size: CGFloat = 20 + private let innerSize: CGFloat + + init(radiusFraction: CGFloat, borderWidth: CGFloat) { + self.radiusFraction = radiusFraction + self.innerSize = self.size - 2 * borderWidth + } + + var body: some View { + ZStack { + Rectangle() + .foregroundColor(.gray) + .frame(width: size, height: size) + .cornerRadius(radiusFraction * size) + + Rectangle() + .foregroundColor(Color(UIColor.systemBackground)) + .frame(width: innerSize, height: innerSize) + .cornerRadius(radiusFraction * innerSize) + } + } + } +} + +//struct ComposePollView_Previews: PreviewProvider { +// static var previews: some View { +// ComposePollView() +// } +//} diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index 4add71e5..086e98cd 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -40,7 +40,7 @@ struct ComposeView: View { } var postButtonEnabled: Bool { - draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions + draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }) } var body: some View { @@ -101,7 +101,7 @@ struct ComposeView: View { header if draft.contentWarningEnabled { - ComposeContentWarningTextField(text: $draft.contentWarning) + ComposeEmojiTextField(text: $draft.contentWarning, placeholder: "Write your warning here") } MainComposeTextView( @@ -109,6 +109,13 @@ struct ComposeView: View { placeholder: Text("What's on your mind?") ) + if let poll = draft.poll { + ComposePollView(draft: draft, poll: poll) + .transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing)))) + .animation(.default) + + } + ComposeAttachmentsList( draft: draft ) @@ -213,7 +220,10 @@ struct ComposeView: View { sensitive: sensitive, spoilerText: contentWarning, visibility: draft.visibility, - language: nil) + language: nil, + pollOptions: draft.poll?.options.map(\.text), + pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), + pollMultiple: draft.poll?.multiple) self.mastodonController.run(request) { (response) in switch response { case let .failure(error):