From ac78fe28078fdfe7f7eaba19a966ae2f5ece1f98 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 30 Jan 2025 10:02:13 -0500 Subject: [PATCH] WIP poll editing --- .../Controllers/ToolbarController.swift | 6 - .../Attachments/AttachmentsSection.swift | 9 +- .../ComposeUI/Views/ComposeDraftView.swift | 37 +++- .../ComposeUI/Views/ComposeToolbarView.swift | 4 - .../ComposeUI/Views/DraftContentEditor.swift | 38 +++- .../ComposeUI/Views/PlatterBackground.swift | 29 +++ .../Sources/ComposeUI/Views/PollEditor.swift | 189 ++++++++++++++++++ .../Sources/TuskerComponents/MenuPicker.swift | 1 + .../TimelineStatusCollectionViewCell.swift | 2 +- 9 files changed, 284 insertions(+), 31 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/PlatterBackground.swift create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift index bcef0cf0..2c88f38a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift @@ -90,10 +90,6 @@ class ToolbarController: ViewController { cwButton MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly) - #if !targetEnvironment(macCatalyst) && !os(visionOS) - // the button has a bunch of extra space by default, but combined with what we add it's too much - .padding(.horizontal, -8) - #endif .disabled(draft.editedStatusID != nil) .disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly) @@ -101,8 +97,6 @@ class ToolbarController: ViewController { localOnlyPicker #if targetEnvironment(macCatalyst) .padding(.leading, 4) - #elseif !os(visionOS) - .padding(.horizontal, -8) #endif .disabled(draft.editedStatusID != nil) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift index 00cc760f..61151c70 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift @@ -197,9 +197,9 @@ private class WrappedCollectionViewController: UIViewController { cell.containingViewController = self cell.setView(AttachmentCollectionViewCellView(attachment: attachment)) } - let addButtonCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + let addButtonCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.containingViewController = self - cell.setView(AddAttachmentButton(viewController: self, enabled: item)) + cell.setView(AddAttachmentButton(viewController: self)) } let collectionView = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout) self.view = collectionView @@ -208,7 +208,7 @@ private class WrappedCollectionViewController: UIViewController { case .attachment(let attachment): return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment) case .addButton: - return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: true) + return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: ()) } } dataSource.reorderingHandlers.canReorderItem = { item in @@ -423,7 +423,7 @@ private class HostingCollectionViewCell: UICollectionViewCell { private struct AddAttachmentButton: View { unowned let viewController: WrappedCollectionViewController - let enabled: Bool + @Environment(\.canAddAttachment) private var enabled @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker @Environment(\.composeUIConfig.presentDrawing) private var presentDrawing @@ -462,6 +462,7 @@ private struct AddAttachmentButton: View { } } .disabled(!enabled) + .animation(.linear(duration: 0.2), value: enabled) } private var iconName: String { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift index 982afeec..032a3265 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift @@ -13,18 +13,11 @@ struct ComposeDraftView: View { @ObservedObject var draft: Draft @FocusState.Binding var focusedField: FocusableField? @Environment(\.currentAccount) private var currentAccount - @EnvironmentObject private var controller: ComposeController var body: some View { HStack(alignment: .top, spacing: 8) { // TODO: scroll effect? - AvatarImageView( - url: currentAccount?.avatar, - size: 50, - style: controller.config.avatarStyle, - fetchAvatar: controller.fetchAvatar - ) - .accessibilityHidden(true) + AvatarView(account: currentAccount) VStack(alignment: .leading, spacing: 4) { if let currentAccount { @@ -35,12 +28,40 @@ struct ComposeDraftView: View { DraftContentEditor(draft: draft, focusedField: $focusedField) + if let poll = draft.poll { + PollEditor(poll: poll) + .padding(.bottom, 4) + // So that during the appearance transition, it's behind the text view. + .zIndex(-1) + .transition(.move(edge: .top).combined(with: .opacity)) + } + AttachmentsSection(draft: draft) + // We want the padding between the poll/attachments to be part of the poll, so it animates in/out with the transition. + // Otherwise, when the poll is added, its bottom edge is aligned with the top edge of the attachments + .padding(.top, draft.poll == nil ? 0 : -4) } + .animation(.snappy, value: draft.poll == nil) } } } +private struct AvatarView: View { + let account: (any AccountProtocol)? + @Environment(\.composeUIConfig.avatarStyle) private var avatarStyle + @EnvironmentObject private var controller: ComposeController + + var body: some View { + AvatarImageView( + url: account?.avatar, + size: 50, + style: avatarStyle, + fetchAvatar: controller.fetchAvatar + ) + .accessibilityHidden(true) + } +} + private struct AccountNameView: View { let account: any AccountProtocol @EnvironmentObject private var controller: ComposeController diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift index e83e8b4e..ac5d0bba 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift @@ -142,10 +142,6 @@ private struct VisibilityButton: View { var body: some View { MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly) - #if !targetEnvironment(macCatalyst) && !os(visionOS) - // the button has a bunch of extra space by default, but combined with what we add it's too much - .padding(.horizontal, -8) - #endif .disabled(draft.editedStatusID != nil) .disabled(instanceFeatures.localOnlyPostsVisibility && draft.localOnly) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift index df723aa6..f9801c5e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift @@ -20,16 +20,13 @@ struct DraftContentEditor: View { HStack(alignment: .firstTextBaseline) { LanguageButton(draft: draft) + TogglePollButton(draft: draft) Spacer() CharactersRemaining(draft: draft) - .padding(.trailing, 4) + .padding(.trailing, 6) } - .padding(.all.subtracting(.top), 2) - } - .background { - RoundedRectangle(cornerRadius: 5) - .fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground)) } + .composePlatterBackground() } private func addAttachments(_ providers: [NSItemProvider]) { @@ -50,7 +47,7 @@ private struct CharactersRemaining: View { var body: some View { Text(verbatim: charsRemaining.description) .foregroundStyle(charsRemaining < 0 ? .red : .secondary) - .font(.callout.monospacedDigit()) + .font(.body.monospacedDigit()) .accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining")) } } @@ -85,11 +82,36 @@ private struct LanguageButton: View { private struct LanguageButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.callout) .foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1)) .padding(.vertical, 2) .padding(.horizontal, 4) .background(.tint.opacity(configuration.isPressed ? 0.15 : 0.2), in: RoundedRectangle(cornerRadius: 3)) .animation(.linear(duration: 0.1), value: configuration.isPressed) + .padding(2) + } +} + +private struct TogglePollButton: View { + @ObservedObject var draft: Draft + @EnvironmentObject private var instanceFeatures: InstanceFeatures + + var body: some View { + Button { + if draft.poll == nil { + draft.poll = Poll(context: DraftsPersistentContainer.shared.viewContext) + } else { + draft.poll = nil + } + } label: { + Image(systemName: draft.poll == nil ? "chart.bar.doc.horizontal" : "chart.bar.horizontal.page.fill") + } + .buttonStyle(LanguageButtonStyle()) + .disabled(disabled) + .animation(.linear(duration: 0.2), value: disabled) + .animation(.linear(duration: 0.2), value: draft.poll == nil) + } + + private var disabled: Bool { + instanceFeatures.mastodonAttachmentRestrictions && draft.attachments.count > 0 } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PlatterBackground.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PlatterBackground.swift new file mode 100644 index 00000000..3164843f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PlatterBackground.swift @@ -0,0 +1,29 @@ +// +// BackgroundPlatterView.swift +// ComposeUI +// +// Created by Shadowfacts on 1/29/25. +// + +import SwiftUI + +extension View { + func composePlatterBackground() -> some View { + self.background { + PlatterBackgroundView() + } + } +} + +private struct PlatterBackgroundView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.composeUIConfig.fillColor) private var fillColor + + var body: some View { + RoundedRectangle(cornerRadius: 5) + // TODO: fillColor is semi-transparent in pure-black dark mode, but it needs to be fully opaque for the poll transition to look right +// .fill(colorScheme == .dark ? fillColor : Color(uiColor: .secondarySystemBackground)) + .fill(Color(uiColor: .secondarySystemBackground)) + + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift new file mode 100644 index 00000000..4036609f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift @@ -0,0 +1,189 @@ +// +// PollEditor.swift +// ComposeUI +// +// Created by Shadowfacts on 1/29/25. +// + +import SwiftUI +import InstanceFeatures +import TuskerComponents + +struct PollEditor: View { + @ObservedObject var poll: Poll + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Poll") + .font(.headline) + + ForEach(poll.pollOptions) { option in + PollOptionEditor(option: option, index: poll.options.index(of: option)) { + self.removeOption(option) + } + } + + AddOptionButton(poll: poll) + + Toggle("Multiple choice", isOn: $poll.multiple) + .padding(.bottom, -8) + + HStack { + Text("Duration") + Spacer() + PollDurationPicker(poll: poll) + .frame(minHeight: 32) + } + } + .padding(.all.subtracting(.bottom), 8) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .composePlatterBackground() + } + + private func removeOption(_ option: PollOption) { + let index = poll.options.index(of: option) + if index != NSNotFound { + var array = poll.options.array + array.remove(at: index) + poll.options = NSMutableOrderedSet(array: array) + } + } +} + +private struct AddOptionButton: View { + @ObservedObject var poll: Poll + @EnvironmentObject private var instanceFeatures: InstanceFeatures + + private var canAddOption: Bool { + if let max = instanceFeatures.maxPollOptionsCount { + poll.options.count < max + } else { + true + } + } + + var body: some View { + Button(action: addOption) { + Label("Add Option", systemImage: "plus") + } + .buttonStyle(.borderless) + .disabled(!canAddOption) + } + + private func addOption() { + let option = PollOption(context: DraftsPersistentContainer.shared.viewContext) + option.poll = poll + poll.options.add(option) + } +} + +private struct PollDurationPicker: View { + @ObservedObject var poll: Poll + @State var duration: PollDuration + + init(poll: Poll) { + self.poll = poll + self._duration = State(wrappedValue: .fromTimeInterval(poll.duration) ?? .oneDay) + } + + private var options: [MenuPicker.Option] { + PollDuration.allCases.map { + .init(value: $0, title: PollDuration.formatter.string(from: $0.timeInterval)!) + } + } + + var body: some View { + MenuPicker(selection: $duration, options: options) + #if os(visionOS) + .onChange(of: duration) { + poll.duration = duration.timeInterval + } + #else + .onChange(of: duration) { newValue in + poll.duration = newValue.timeInterval + } + #endif + } +} + +private enum PollDuration: Hashable, Equatable, CaseIterable { + case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays + + static let formatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.maximumUnitCount = 1 + f.unitsStyle = .full + f.allowedUnits = [.weekOfMonth, .day, .hour, .minute] + return f + }() + + static func fromTimeInterval(_ ti: TimeInterval) -> PollDuration? { + 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 + } + } +} + +private struct PollOptionEditor: View { + @ObservedObject var option: PollOption + let index: Int + let removeOption: () -> Void + @EnvironmentObject private var instanceFeatures: InstanceFeatures + + var placeholder: String { + if index != NSNotFound { + "Option \(index + 1)" + } else { + "" + } + } + + var body: some View { + HStack(spacing: 4) { + Button(role: .destructive, action: removeOption) { + Label("Remove option", systemImage: "minus") + } + .labelStyle(.iconOnly) + .buttonStyle(PollOptionButtonStyle()) + .accessibilityLabel("Remove option") + EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars) + } + } +} + +private struct PollOptionButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(.white) + .font(.body.bold()) + .padding(4) + .frame(width: 20, height: 20) + .background { + let color = configuration.role == .destructive ? Color.red : .green + let opacity = configuration.isPressed ? 0.8 : 1 + Circle() + .fill(color.opacity(opacity)) + } + } +} diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift index 08f67c1e..2b185f55 100644 --- a/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift @@ -59,6 +59,7 @@ public struct MenuPicker: UIViewRepresentable { #if targetEnvironment(macCatalyst) config.macIdiomStyle = .bordered #endif + config.contentInsets = .zero return config } diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 2e75d7a6..72d38f68 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -181,8 +181,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti private(set) lazy var contentContainer = StatusContentContainer(arrangedSubviews: [ contentTextView, cardView, - attachmentsView, pollView, + attachmentsView, ] as! [any StatusContentView], useTopSpacer: false).configure { $0.setContentHuggingPriority(.defaultLow, for: .vertical) }