WIP poll editing
This commit is contained in:
parent
fbbcb0d07d
commit
ac78fe2807
@ -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)
|
||||
}
|
||||
|
@ -197,9 +197,9 @@ private class WrappedCollectionViewController: UIViewController {
|
||||
cell.containingViewController = self
|
||||
cell.setView(AttachmentCollectionViewCellView(attachment: attachment))
|
||||
}
|
||||
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Bool> { [unowned self] cell, indexPath, item in
|
||||
let addButtonCell = UICollectionView.CellRegistration<HostingCollectionViewCell, Void> { [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 {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
|
||||
}
|
||||
}
|
189
Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift
Normal file
189
Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift
Normal file
@ -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<PollDuration>.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))
|
||||
}
|
||||
}
|
||||
}
|
@ -59,6 +59,7 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
config.macIdiomStyle = .bordered
|
||||
#endif
|
||||
config.contentInsets = .zero
|
||||
return config
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user