2026-02-20 00:09:02 -08:00
|
|
|
import Observation
|
2026-05-02 19:47:38 -07:00
|
|
|
import PhotosUI
|
2026-02-20 00:09:02 -08:00
|
|
|
import SwiftUI
|
2026-05-02 19:47:38 -07:00
|
|
|
import UniformTypeIdentifiers
|
|
|
|
|
import UIKit
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
struct SybilWorkspaceView: View {
|
|
|
|
|
@Bindable var viewModel: SybilViewModel
|
2026-05-02 22:46:25 -07:00
|
|
|
var composerFocusRequest: Int = 0
|
|
|
|
|
var onRequestNewChat: (() -> Void)? = nil
|
2026-02-20 00:09:02 -08:00
|
|
|
@FocusState private var composerFocused: Bool
|
2026-05-02 19:47:38 -07:00
|
|
|
@State private var isShowingAttachmentOptions = false
|
|
|
|
|
@State private var isShowingFileImporter = false
|
|
|
|
|
@State private var isShowingPhotoPicker = false
|
|
|
|
|
@State private var photoPickerItems: [PhotosPickerItem] = []
|
|
|
|
|
@State private var isComposerDropTargeted = false
|
2026-05-02 22:46:25 -07:00
|
|
|
@State private var newChatSwipeOffset: CGFloat = 0
|
|
|
|
|
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
|
|
|
|
@State private var newChatSwipeIsActive = false
|
|
|
|
|
@State private var newChatSwipeHasLatched = false
|
|
|
|
|
@State private var newChatSwipeDidTriggerHaptic = false
|
|
|
|
|
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
private var isSettingsSelected: Bool {
|
|
|
|
|
if case .settings = viewModel.selectedItem {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
private var showsHeader: Bool {
|
2026-05-02 17:03:02 -07:00
|
|
|
viewModel.errorMessage != nil
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:25:24 -07:00
|
|
|
private var transcriptScrollContextID: String {
|
|
|
|
|
if viewModel.draftKind == .chat {
|
|
|
|
|
return "draft-chat"
|
|
|
|
|
}
|
|
|
|
|
if case let .chat(chatID) = viewModel.selectedItem {
|
|
|
|
|
return "chat:\(chatID)"
|
|
|
|
|
}
|
|
|
|
|
return "chat:none"
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 22:46:25 -07:00
|
|
|
private var canSwipeToCreateChat: Bool {
|
|
|
|
|
guard onRequestNewChat != nil else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
guard !viewModel.isSending, viewModel.draftKind == nil else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
guard case .chat = viewModel.selectedItem else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
var body: some View {
|
2026-05-02 22:46:25 -07:00
|
|
|
ZStack(alignment: .trailing) {
|
|
|
|
|
if canSwipeToCreateChat {
|
|
|
|
|
NewChatSwipeBackdrop(
|
|
|
|
|
progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
|
|
|
|
|
hasLatched: newChatSwipeHasLatched
|
|
|
|
|
)
|
|
|
|
|
.padding(.trailing, 18)
|
|
|
|
|
.padding(.vertical, 20)
|
|
|
|
|
.allowsHitTesting(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
workspaceContent
|
|
|
|
|
.compositingGroup()
|
|
|
|
|
.offset(x: newChatSwipeOffset)
|
|
|
|
|
.blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth))
|
|
|
|
|
}
|
|
|
|
|
.background(SybilTheme.background)
|
|
|
|
|
.navigationTitle(viewModel.selectedTitle)
|
|
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
|
|
.toolbarRole(.editor)
|
|
|
|
|
.toolbar {
|
|
|
|
|
if !isSettingsSelected {
|
|
|
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
|
|
|
if viewModel.isSearchMode {
|
|
|
|
|
searchModeChip
|
|
|
|
|
} else {
|
|
|
|
|
providerModelMenu
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
.onChange(of: canSwipeToCreateChat) { _, isEnabled in
|
|
|
|
|
guard !isEnabled else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resetNewChatSwipe(animated: false)
|
|
|
|
|
}
|
|
|
|
|
.task(id: composerFocusRequest) {
|
|
|
|
|
await focusComposerIfRequested()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var workspaceContent: some View {
|
2026-02-20 00:09:02 -08:00
|
|
|
VStack(spacing: 0) {
|
2026-05-02 16:23:00 -07:00
|
|
|
if showsHeader {
|
|
|
|
|
header
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
Divider()
|
|
|
|
|
.overlay(SybilTheme.border)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
Group {
|
|
|
|
|
if isSettingsSelected {
|
|
|
|
|
SybilSettingsView(viewModel: viewModel)
|
|
|
|
|
} else if viewModel.isSearchMode {
|
|
|
|
|
SybilSearchResultsView(
|
|
|
|
|
search: viewModel.selectedSearch,
|
|
|
|
|
isLoading: viewModel.isLoadingSelection,
|
2026-05-02 16:48:01 -07:00
|
|
|
isRunning: viewModel.isSending,
|
|
|
|
|
isStartingChat: viewModel.isCreatingSearchChat
|
|
|
|
|
) {
|
|
|
|
|
Task {
|
|
|
|
|
await viewModel.startChatFromSelectedSearch()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
} else {
|
|
|
|
|
SybilChatTranscriptView(
|
|
|
|
|
messages: viewModel.displayedMessages,
|
|
|
|
|
isLoading: viewModel.isLoadingSelection,
|
|
|
|
|
isSending: viewModel.isSending
|
|
|
|
|
)
|
2026-05-02 22:25:24 -07:00
|
|
|
.id(transcriptScrollContextID)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
2026-05-02 22:46:25 -07:00
|
|
|
.background {
|
|
|
|
|
NewChatSwipePanInstaller(
|
|
|
|
|
isEnabled: canSwipeToCreateChat,
|
|
|
|
|
onBegan: { width in
|
|
|
|
|
beginNewChatSwipe(containerWidth: width)
|
|
|
|
|
},
|
|
|
|
|
onChanged: { translationX, width in
|
|
|
|
|
updateNewChatSwipe(with: translationX, containerWidth: width)
|
|
|
|
|
},
|
|
|
|
|
onEnded: { translationX, width, didFinish in
|
|
|
|
|
finishNewChatSwipe(
|
|
|
|
|
translationX: translationX,
|
|
|
|
|
containerWidth: width,
|
|
|
|
|
didFinish: didFinish
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
if viewModel.showsComposer {
|
|
|
|
|
Divider()
|
|
|
|
|
.overlay(SybilTheme.border)
|
|
|
|
|
composerBar
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-02 22:46:25 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
|
|
|
|
let update = {
|
|
|
|
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
|
|
|
|
newChatSwipeIsActive = true
|
|
|
|
|
newChatSwipeHasLatched = false
|
|
|
|
|
newChatSwipeDidTriggerHaptic = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var transaction = Transaction()
|
|
|
|
|
transaction.disablesAnimations = true
|
|
|
|
|
withTransaction(transaction, update)
|
|
|
|
|
|
|
|
|
|
if newChatSwipeFeedbackGenerator == nil {
|
|
|
|
|
newChatSwipeFeedbackGenerator = UIImpactFeedbackGenerator(style: .rigid)
|
|
|
|
|
}
|
|
|
|
|
newChatSwipeFeedbackGenerator?.prepare()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updateNewChatSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
|
|
|
|
let nextOffset = NewChatSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
|
|
|
|
let wasLatched = newChatSwipeHasLatched
|
|
|
|
|
let nextLatched = NewChatSwipeMetrics.isLatched(
|
|
|
|
|
offset: nextOffset,
|
|
|
|
|
width: containerWidth,
|
|
|
|
|
isCurrentlyLatched: newChatSwipeHasLatched
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var transaction = Transaction()
|
|
|
|
|
transaction.disablesAnimations = true
|
|
|
|
|
withTransaction(transaction) {
|
|
|
|
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
|
|
|
|
newChatSwipeOffset = nextOffset
|
|
|
|
|
newChatSwipeHasLatched = nextLatched
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if nextLatched && !wasLatched && !newChatSwipeDidTriggerHaptic {
|
|
|
|
|
newChatSwipeFeedbackGenerator?.impactOccurred(intensity: 0.95)
|
|
|
|
|
newChatSwipeDidTriggerHaptic = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func finishNewChatSwipe(translationX: CGFloat, containerWidth: CGFloat, didFinish: Bool) {
|
|
|
|
|
guard newChatSwipeIsActive else {
|
|
|
|
|
resetNewChatSwipe(animated: false)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateNewChatSwipe(with: translationX, containerWidth: containerWidth)
|
|
|
|
|
|
|
|
|
|
if didFinish && newChatSwipeHasLatched {
|
|
|
|
|
onRequestNewChat?()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetNewChatSwipe(animated: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func resetNewChatSwipe(animated: Bool) {
|
|
|
|
|
let reset = {
|
|
|
|
|
newChatSwipeOffset = 0
|
|
|
|
|
newChatSwipeIsActive = false
|
|
|
|
|
newChatSwipeHasLatched = false
|
|
|
|
|
newChatSwipeDidTriggerHaptic = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if animated {
|
|
|
|
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
|
|
|
|
reset()
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
2026-05-02 22:46:25 -07:00
|
|
|
} else {
|
|
|
|
|
reset()
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
2026-05-02 22:46:25 -07:00
|
|
|
|
|
|
|
|
newChatSwipeFeedbackGenerator = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
private func focusComposerIfRequested() async {
|
|
|
|
|
guard composerFocusRequest > 0 else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Task.yield()
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(80))
|
|
|
|
|
|
|
|
|
|
guard viewModel.showsComposer, !viewModel.isSearchMode else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
composerFocused = true
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var header: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
if let error = viewModel.errorMessage {
|
|
|
|
|
Text(error)
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.footnote))
|
2026-02-20 00:09:02 -08:00
|
|
|
.foregroundStyle(SybilTheme.danger)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
.padding(.vertical, 12)
|
2026-05-02 16:23:00 -07:00
|
|
|
.background(SybilTheme.panelGradient.opacity(0.58))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 17:03:02 -07:00
|
|
|
private var providerModelMenu: some View {
|
2026-05-02 16:23:00 -07:00
|
|
|
Menu {
|
|
|
|
|
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
|
|
|
|
.font(.sybil(.caption))
|
|
|
|
|
|
|
|
|
|
Divider()
|
|
|
|
|
|
|
|
|
|
ForEach(Provider.allCases, id: \.self) { candidate in
|
|
|
|
|
Menu(candidate.displayName) {
|
|
|
|
|
let models = viewModel.modelOptions(for: candidate)
|
|
|
|
|
if models.isEmpty {
|
|
|
|
|
Text("No models")
|
|
|
|
|
} else {
|
|
|
|
|
ForEach(models, id: \.self) { candidateModel in
|
|
|
|
|
Button {
|
|
|
|
|
viewModel.setProvider(candidate, model: candidateModel)
|
|
|
|
|
} label: {
|
|
|
|
|
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
|
|
|
|
Label(candidateModel, systemImage: "checkmark")
|
|
|
|
|
} else {
|
|
|
|
|
Text(candidateModel)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: "ellipsis")
|
|
|
|
|
.font(.system(size: 18, weight: .semibold))
|
|
|
|
|
.foregroundStyle(SybilTheme.text)
|
|
|
|
|
.frame(width: 34, height: 34)
|
|
|
|
|
.background(
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(SybilTheme.surface.opacity(0.78))
|
|
|
|
|
)
|
|
|
|
|
.overlay(
|
|
|
|
|
Circle()
|
|
|
|
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
.accessibilityLabel("Provider and model")
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 17:03:02 -07:00
|
|
|
private var searchModeChip: some View {
|
|
|
|
|
Label("Search", systemImage: "globe")
|
|
|
|
|
.font(.sybil(.caption, weight: .medium))
|
|
|
|
|
.foregroundStyle(SybilTheme.accent)
|
|
|
|
|
.padding(.horizontal, 10)
|
|
|
|
|
.padding(.vertical, 7)
|
|
|
|
|
.background(
|
|
|
|
|
Capsule()
|
|
|
|
|
.fill(SybilTheme.accent.opacity(0.10))
|
|
|
|
|
.overlay(
|
|
|
|
|
Capsule()
|
|
|
|
|
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
|
2026-02-20 00:09:02 -08:00
|
|
|
)
|
2026-05-02 17:03:02 -07:00
|
|
|
)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var composerBar: some View {
|
2026-05-02 19:47:38 -07:00
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
if !viewModel.isSearchMode && !viewModel.composerAttachments.isEmpty {
|
|
|
|
|
SybilAttachmentListView(
|
|
|
|
|
attachments: viewModel.composerAttachments,
|
|
|
|
|
tone: .composer
|
|
|
|
|
) { attachmentID in
|
|
|
|
|
viewModel.removeComposerAttachment(id: attachmentID)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 19:47:38 -07:00
|
|
|
HStack(alignment: .bottom, spacing: 10) {
|
|
|
|
|
if !viewModel.isSearchMode {
|
|
|
|
|
Button {
|
|
|
|
|
isShowingAttachmentOptions = true
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: "paperclip")
|
|
|
|
|
.font(.system(size: 17, weight: .semibold))
|
|
|
|
|
.frame(width: 40, height: 40)
|
|
|
|
|
.background(
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(SybilTheme.surface)
|
2026-05-02 16:23:00 -07:00
|
|
|
)
|
2026-05-02 19:47:38 -07:00
|
|
|
.overlay(
|
|
|
|
|
Circle()
|
|
|
|
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
.foregroundStyle(viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.disabled(viewModel.isSending)
|
|
|
|
|
.accessibilityLabel("Attach file")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TextField(
|
|
|
|
|
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
|
|
|
|
text: $viewModel.composer,
|
|
|
|
|
axis: .vertical
|
|
|
|
|
)
|
|
|
|
|
.focused($composerFocused)
|
|
|
|
|
.textInputAutocapitalization(.sentences)
|
|
|
|
|
.autocorrectionDisabled(false)
|
|
|
|
|
.lineLimit(1 ... 6)
|
|
|
|
|
.submitLabel(.send)
|
|
|
|
|
.onSubmit {
|
|
|
|
|
Task {
|
|
|
|
|
await viewModel.sendComposer()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.padding(.vertical, 10)
|
|
|
|
|
.background(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.fill(SybilTheme.composerGradient)
|
|
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
|
|
|
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.foregroundStyle(SybilTheme.text)
|
|
|
|
|
|
|
|
|
|
Button {
|
|
|
|
|
Task {
|
|
|
|
|
await viewModel.sendComposer()
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
|
|
|
|
.font(.system(size: 17, weight: .semibold))
|
|
|
|
|
.frame(width: 40, height: 40)
|
|
|
|
|
.background(
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(
|
|
|
|
|
viewModel.canSendComposer
|
|
|
|
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
|
|
|
|
: AnyShapeStyle(SybilTheme.surface)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.disabled(!viewModel.canSendComposer)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 12)
|
2026-05-02 16:23:00 -07:00
|
|
|
.background(
|
|
|
|
|
LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
SybilTheme.background.opacity(0.18),
|
|
|
|
|
SybilTheme.background.opacity(0.96)
|
|
|
|
|
],
|
|
|
|
|
startPoint: .top,
|
|
|
|
|
endPoint: .bottom
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-02 19:47:38 -07:00
|
|
|
.overlay {
|
|
|
|
|
if isComposerDropTargeted && !viewModel.isSearchMode {
|
|
|
|
|
RoundedRectangle(cornerRadius: 18)
|
|
|
|
|
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
|
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 10)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
|
|
|
|
if viewModel.isSearchMode || viewModel.isSending {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Task {
|
|
|
|
|
await importAttachmentsFromItemProviders(providers)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
.confirmationDialog("Add attachment", isPresented: $isShowingAttachmentOptions, titleVisibility: .visible) {
|
|
|
|
|
Button("Photo Library") {
|
|
|
|
|
isShowingPhotoPicker = true
|
|
|
|
|
}
|
|
|
|
|
Button("Files") {
|
|
|
|
|
isShowingFileImporter = true
|
|
|
|
|
}
|
|
|
|
|
if canPasteFromClipboard {
|
|
|
|
|
Button("Paste from Clipboard") {
|
|
|
|
|
Task {
|
|
|
|
|
await pasteAttachmentsFromClipboard()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Button("Cancel", role: .cancel) {}
|
|
|
|
|
}
|
|
|
|
|
.photosPicker(
|
|
|
|
|
isPresented: $isShowingPhotoPicker,
|
|
|
|
|
selection: $photoPickerItems,
|
|
|
|
|
maxSelectionCount: max(1, SybilChatAttachmentSupport.maxAttachmentsPerMessage - viewModel.composerAttachments.count),
|
|
|
|
|
matching: .images
|
|
|
|
|
)
|
|
|
|
|
.fileImporter(
|
|
|
|
|
isPresented: $isShowingFileImporter,
|
|
|
|
|
allowedContentTypes: [.item],
|
|
|
|
|
allowsMultipleSelection: true
|
|
|
|
|
) { result in
|
|
|
|
|
Task {
|
|
|
|
|
do {
|
|
|
|
|
let urls = try result.get()
|
|
|
|
|
let attachments = try SybilChatAttachmentSupport.buildAttachments(from: urls)
|
|
|
|
|
try await MainActor.run {
|
|
|
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
|
|
|
}
|
|
|
|
|
composerFocused = true
|
|
|
|
|
} catch {
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
viewModel.errorMessage = error.localizedDescription
|
|
|
|
|
}
|
|
|
|
|
SybilLog.error(SybilLog.ui, "File import failed", error: error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.onChange(of: photoPickerItems) { _, items in
|
|
|
|
|
guard !items.isEmpty else { return }
|
|
|
|
|
Task {
|
|
|
|
|
do {
|
|
|
|
|
let attachments = try await loadAttachmentsFromPhotoPickerItems(items)
|
|
|
|
|
try await MainActor.run {
|
|
|
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
|
|
|
photoPickerItems = []
|
|
|
|
|
}
|
|
|
|
|
composerFocused = true
|
|
|
|
|
} catch {
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
viewModel.errorMessage = error.localizedDescription
|
|
|
|
|
photoPickerItems = []
|
|
|
|
|
}
|
|
|
|
|
SybilLog.error(SybilLog.ui, "Photo import failed", error: error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var canPasteFromClipboard: Bool {
|
|
|
|
|
let pasteboard = UIPasteboard.general
|
|
|
|
|
if pasteboard.hasImages {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if let url = pasteboard.url, url.isFileURL {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if let string = pasteboard.string, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
|
|
|
|
do {
|
|
|
|
|
let attachments = try await SybilChatAttachmentSupport.buildAttachments(from: providers)
|
|
|
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
|
|
|
composerFocused = true
|
|
|
|
|
} catch {
|
|
|
|
|
viewModel.errorMessage = error.localizedDescription
|
|
|
|
|
SybilLog.error(SybilLog.ui, "Clipboard/drop attachment import failed", error: error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func loadAttachmentsFromPhotoPickerItems(_ items: [PhotosPickerItem]) async throws -> [ChatAttachment] {
|
|
|
|
|
var attachments: [ChatAttachment] = []
|
|
|
|
|
|
|
|
|
|
for item in items {
|
|
|
|
|
guard let data = try await item.loadTransferable(type: Data.self) else {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let contentType = item.supportedContentTypes.first(where: { $0.conforms(to: .image) })
|
|
|
|
|
let filename = contentType?.preferredFilenameExtension.map { "photo.\($0)" } ?? "photo.jpg"
|
|
|
|
|
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(data: data, filename: filename, contentType: contentType))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return attachments
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
private func pasteAttachmentsFromClipboard() async {
|
|
|
|
|
do {
|
|
|
|
|
let pasteboard = UIPasteboard.general
|
|
|
|
|
var attachments: [ChatAttachment] = []
|
|
|
|
|
|
|
|
|
|
if let image = pasteboard.image {
|
|
|
|
|
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(image: image))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let url = pasteboard.url, url.isFileURL {
|
|
|
|
|
attachments.append(contentsOf: try SybilChatAttachmentSupport.buildAttachments(from: [url]))
|
|
|
|
|
} else if let text = pasteboard.string?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
|
|
|
|
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
|
|
|
composerFocused = true
|
|
|
|
|
} catch {
|
|
|
|
|
viewModel.errorMessage = error.localizedDescription
|
|
|
|
|
SybilLog.error(SybilLog.ui, "Clipboard attachment import failed", error: error)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-02 22:46:25 -07:00
|
|
|
|
|
|
|
|
enum NewChatSwipeMetrics {
|
|
|
|
|
static let referenceWidth: CGFloat = 390
|
|
|
|
|
static let horizontalActivationDistance: CGFloat = 18
|
|
|
|
|
static let directionDominanceRatio: CGFloat = 1.22
|
|
|
|
|
static let minimumLeftwardVelocity: CGFloat = 55
|
|
|
|
|
static let latchHysteresis: CGFloat = 32
|
|
|
|
|
|
|
|
|
|
static func maxTravel(for width: CGFloat) -> CGFloat {
|
|
|
|
|
min(max(width * 0.46, 156), 240)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func latchDistance(for width: CGFloat) -> CGFloat {
|
|
|
|
|
min(max(width * 0.28, 112), 152)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
|
|
|
|
max(min(rawTranslation, 0), -maxTravel(for: width))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
|
|
|
|
let travel = maxTravel(for: width)
|
|
|
|
|
guard travel > 0 else {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return min(max(abs(offset) / travel, 0), 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
|
|
|
|
progress(for: offset, width: width) * 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func shouldBeginPan(
|
|
|
|
|
leftwardTravel: CGFloat,
|
|
|
|
|
verticalTravel: CGFloat,
|
|
|
|
|
leftwardVelocity: CGFloat,
|
|
|
|
|
verticalVelocity: CGFloat
|
|
|
|
|
) -> Bool {
|
|
|
|
|
guard leftwardTravel > 0 || leftwardVelocity > 0 else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if leftwardTravel >= horizontalActivationDistance,
|
|
|
|
|
leftwardTravel >= verticalTravel * directionDominanceRatio {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return leftwardVelocity >= minimumLeftwardVelocity &&
|
|
|
|
|
leftwardVelocity >= verticalVelocity * directionDominanceRatio
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func latchReleaseDistance(for width: CGFloat) -> CGFloat {
|
|
|
|
|
max(latchDistance(for: width) - latchHysteresis, horizontalActivationDistance)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
|
|
|
|
let distance = abs(offset)
|
|
|
|
|
if isCurrentlyLatched {
|
|
|
|
|
return distance >= latchReleaseDistance(for: width)
|
|
|
|
|
}
|
|
|
|
|
return distance >= latchDistance(for: width)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|
|
|
|
var isEnabled: Bool
|
|
|
|
|
var onBegan: (CGFloat) -> Void
|
|
|
|
|
var onChanged: (CGFloat, CGFloat) -> Void
|
|
|
|
|
var onEnded: (CGFloat, CGFloat, Bool) -> Void
|
|
|
|
|
|
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
|
|
|
Coordinator()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func makeUIView(context: Context) -> InstallerView {
|
|
|
|
|
let view = InstallerView()
|
|
|
|
|
view.isUserInteractionEnabled = false
|
|
|
|
|
view.coordinator = context.coordinator
|
|
|
|
|
context.coordinator.markerView = view
|
|
|
|
|
return view
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateUIView(_ uiView: InstallerView, context: Context) {
|
|
|
|
|
context.coordinator.update(
|
|
|
|
|
isEnabled: isEnabled,
|
|
|
|
|
onBegan: onBegan,
|
|
|
|
|
onChanged: onChanged,
|
|
|
|
|
onEnded: onEnded
|
|
|
|
|
)
|
|
|
|
|
context.coordinator.markerView = uiView
|
|
|
|
|
context.coordinator.installIfPossible()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) {
|
|
|
|
|
coordinator.detach()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class InstallerView: UIView {
|
|
|
|
|
weak var coordinator: Coordinator?
|
|
|
|
|
|
|
|
|
|
override func didMoveToWindow() {
|
|
|
|
|
super.didMoveToWindow()
|
|
|
|
|
coordinator?.markerView = self
|
|
|
|
|
coordinator?.installIfPossible()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func layoutSubviews() {
|
|
|
|
|
super.layoutSubviews()
|
|
|
|
|
coordinator?.configureScrollViewFailureRequirements()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
|
|
|
|
|
weak var markerView: UIView?
|
|
|
|
|
private weak var installedWindow: UIWindow?
|
|
|
|
|
private let panGesture = UIPanGestureRecognizer()
|
|
|
|
|
private var preparedScrollRecognizers: Set<ObjectIdentifier> = []
|
|
|
|
|
|
|
|
|
|
private var isEnabled = false
|
|
|
|
|
private var onBegan: (CGFloat) -> Void = { _ in }
|
|
|
|
|
private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in }
|
|
|
|
|
private var onEnded: (CGFloat, CGFloat, Bool) -> Void = { _, _, _ in }
|
|
|
|
|
|
|
|
|
|
override init() {
|
|
|
|
|
super.init()
|
|
|
|
|
panGesture.addTarget(self, action: #selector(handlePan(_:)))
|
|
|
|
|
panGesture.cancelsTouchesInView = true
|
|
|
|
|
panGesture.delaysTouchesBegan = false
|
|
|
|
|
panGesture.delaysTouchesEnded = false
|
|
|
|
|
panGesture.delegate = self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func update(
|
|
|
|
|
isEnabled: Bool,
|
|
|
|
|
onBegan: @escaping (CGFloat) -> Void,
|
|
|
|
|
onChanged: @escaping (CGFloat, CGFloat) -> Void,
|
|
|
|
|
onEnded: @escaping (CGFloat, CGFloat, Bool) -> Void
|
|
|
|
|
) {
|
|
|
|
|
self.isEnabled = isEnabled
|
|
|
|
|
self.onBegan = onBegan
|
|
|
|
|
self.onChanged = onChanged
|
|
|
|
|
self.onEnded = onEnded
|
|
|
|
|
panGesture.isEnabled = isEnabled
|
|
|
|
|
configureScrollViewFailureRequirements()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func installIfPossible() {
|
|
|
|
|
guard let window = markerView?.window else {
|
|
|
|
|
detach()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard installedWindow !== window else {
|
|
|
|
|
configureScrollViewFailureRequirements()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
installedWindow?.removeGestureRecognizer(panGesture)
|
|
|
|
|
window.addGestureRecognizer(panGesture)
|
|
|
|
|
installedWindow = window
|
|
|
|
|
configureScrollViewFailureRequirements()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func detach() {
|
|
|
|
|
installedWindow?.removeGestureRecognizer(panGesture)
|
|
|
|
|
installedWindow = nil
|
|
|
|
|
preparedScrollRecognizers = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func configureScrollViewFailureRequirements() {
|
|
|
|
|
guard isEnabled, let markerView, let window = markerView.window else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let markerFrame = markerView.convert(markerView.bounds, to: window)
|
|
|
|
|
for scrollView in window.sybilDescendantScrollViews {
|
|
|
|
|
let recognizerID = ObjectIdentifier(scrollView.panGestureRecognizer)
|
|
|
|
|
guard !preparedScrollRecognizers.contains(recognizerID) else {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let scrollFrame = scrollView.convert(scrollView.bounds, to: window)
|
|
|
|
|
if scrollFrame.intersects(markerFrame) {
|
|
|
|
|
scrollView.panGestureRecognizer.require(toFail: panGesture)
|
|
|
|
|
preparedScrollRecognizers.insert(recognizerID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
|
|
|
|
guard let markerView else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let width = max(markerView.bounds.width, 1)
|
|
|
|
|
let translationX = recognizer.translation(in: markerView).x
|
|
|
|
|
|
|
|
|
|
switch recognizer.state {
|
|
|
|
|
case .began:
|
|
|
|
|
onBegan(width)
|
|
|
|
|
onChanged(translationX, width)
|
|
|
|
|
|
|
|
|
|
case .changed:
|
|
|
|
|
onChanged(translationX, width)
|
|
|
|
|
|
|
|
|
|
case .ended:
|
|
|
|
|
onEnded(translationX, width, true)
|
|
|
|
|
|
|
|
|
|
case .cancelled, .failed:
|
|
|
|
|
onEnded(translationX, width, false)
|
|
|
|
|
|
|
|
|
|
case .possible:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
onEnded(translationX, width, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
|
|
|
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return markerView.bounds.contains(touch.location(in: markerView))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
|
|
|
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let translation = panGesture.translation(in: markerView)
|
|
|
|
|
let velocity = panGesture.velocity(in: markerView)
|
|
|
|
|
return NewChatSwipeMetrics.shouldBeginPan(
|
|
|
|
|
leftwardTravel: max(-translation.x, 0),
|
|
|
|
|
verticalTravel: abs(translation.y),
|
|
|
|
|
leftwardVelocity: max(-velocity.x, 0),
|
|
|
|
|
verticalVelocity: abs(velocity.y)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func gestureRecognizer(
|
|
|
|
|
_ gestureRecognizer: UIGestureRecognizer,
|
|
|
|
|
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
|
|
|
|
) -> Bool {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension UIView {
|
|
|
|
|
var sybilDescendantScrollViews: [UIScrollView] {
|
|
|
|
|
var scrollViews: [UIScrollView] = []
|
|
|
|
|
collectSybilScrollViews(into: &scrollViews)
|
|
|
|
|
return scrollViews
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func collectSybilScrollViews(into scrollViews: inout [UIScrollView]) {
|
|
|
|
|
if let scrollView = self as? UIScrollView {
|
|
|
|
|
scrollViews.append(scrollView)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for subview in subviews {
|
|
|
|
|
subview.collectSybilScrollViews(into: &scrollViews)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct NewChatSwipeBackdrop: View {
|
|
|
|
|
var progress: CGFloat
|
|
|
|
|
var hasLatched: Bool
|
|
|
|
|
|
|
|
|
|
private var clampedProgress: CGFloat {
|
|
|
|
|
min(max(progress, 0), 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
ZStack(alignment: .trailing) {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.16 + (0.18 * clampedProgress)))
|
|
|
|
|
.frame(width: 176, height: 176)
|
|
|
|
|
.blur(radius: 44)
|
|
|
|
|
.offset(x: 38, y: 18)
|
|
|
|
|
|
|
|
|
|
ZStack {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(
|
|
|
|
|
RadialGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.28),
|
|
|
|
|
SybilTheme.surface.opacity(0.78)
|
|
|
|
|
],
|
|
|
|
|
center: .topLeading,
|
|
|
|
|
startRadius: 8,
|
|
|
|
|
endRadius: 58
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.overlay(
|
|
|
|
|
Circle()
|
|
|
|
|
.stroke(
|
|
|
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.44 + (0.24 * clampedProgress)),
|
|
|
|
|
lineWidth: 1
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.shadow(
|
|
|
|
|
color: (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.24 + (0.20 * clampedProgress)),
|
|
|
|
|
radius: 24,
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(
|
|
|
|
|
AngularGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.20),
|
|
|
|
|
Color.clear,
|
|
|
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.34)
|
|
|
|
|
],
|
|
|
|
|
center: .center
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.frame(width: 72, height: 72)
|
|
|
|
|
.blur(radius: 10)
|
|
|
|
|
|
|
|
|
|
Image(systemName: hasLatched ? "checkmark" : "plus")
|
|
|
|
|
.font(.system(size: 31, weight: .bold, design: .rounded))
|
|
|
|
|
.foregroundStyle(SybilTheme.text)
|
|
|
|
|
.symbolEffect(.bounce, value: hasLatched)
|
|
|
|
|
|
|
|
|
|
Image(systemName: "sparkle")
|
|
|
|
|
.font(.system(size: 11, weight: .semibold))
|
|
|
|
|
.foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90))
|
|
|
|
|
.offset(x: -26, y: -25)
|
|
|
|
|
}
|
|
|
|
|
.frame(width: 92, height: 92)
|
|
|
|
|
.background(
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(SybilTheme.surface.opacity(0.42))
|
|
|
|
|
.blur(radius: 16)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
|
|
|
|
.opacity(clampedProgress)
|
|
|
|
|
.offset(x: (1 - clampedProgress) * 28)
|
|
|
|
|
.animation(.easeOut(duration: 0.16), value: hasLatched)
|
|
|
|
|
.accessibilityHidden(true)
|
|
|
|
|
}
|
|
|
|
|
}
|