Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift

1234 lines
43 KiB
Swift

import ImageIO
import Observation
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
import UIKit
struct SybilWorkspaceView: View {
@Bindable var viewModel: SybilViewModel
var composerFocusRequest: Int = 0
var usesCustomChatNavigation: Bool = false
var onRequestNewChat: (() -> Void)? = nil
@FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss
@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
@State private var newChatSwipeOffset: CGFloat = 0
@State private var newChatSwipeCompletionOffset: CGFloat = 0
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
@State private var newChatSwipeIsActive = false
@State private var newChatSwipeIsCompleting = false
@State private var newChatSwipeHasLatched = false
@State private var newChatSwipeDidTriggerHaptic = false
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
private let customChatNavigationContentInset: CGFloat = 96
private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem {
return true
}
return false
}
private var showsHeader: Bool {
viewModel.errorMessage != nil
}
private var showsCustomChatNavigation: Bool {
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
}
private var transcriptScrollContextID: String {
if viewModel.draftKind == .chat {
return "draft-chat"
}
if case let .chat(chatID) = viewModel.selectedItem {
return "chat:\(chatID)"
}
return "chat:none"
}
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
}
private var canRecognizeNewChatSwipe: Bool {
canSwipeToCreateChat && !newChatSwipeIsCompleting
}
private var showsNewChatSwipeBackdrop: Bool {
canSwipeToCreateChat || newChatSwipeIsCompleting
}
var body: some View {
ZStack(alignment: .trailing) {
if showsNewChatSwipeBackdrop {
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))
}
.offset(x: newChatSwipeCompletionOffset)
.background(SybilTheme.background)
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbarRole(.editor)
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
.toolbar {
if !isSettingsSelected && !showsCustomChatNavigation {
ToolbarItem(placement: .topBarTrailing) {
if viewModel.isSearchMode {
searchModeChip
} else {
providerModelToolbarMenu
}
}
}
}
.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 {
ZStack(alignment: .top) {
workspaceContentStack
if showsCustomChatNavigation {
SybilChatCharacterBackdrop(isBusy: viewModel.isSending)
.allowsHitTesting(false)
customChatNavigationBar
}
}
}
private var workspaceContentStack: some View {
VStack(spacing: 0) {
if showsHeader {
header
Divider()
.overlay(SybilTheme.border)
}
Group {
if isSettingsSelected {
SybilSettingsView(viewModel: viewModel)
} else if viewModel.isSearchMode {
SybilSearchResultsView(
search: viewModel.selectedSearch,
isLoading: viewModel.isLoadingSelection,
isRunning: viewModel.isSending,
isStartingChat: viewModel.isCreatingSearchChat
) {
Task {
await viewModel.startChatFromSelectedSearch()
}
}
} else {
SybilChatTranscriptView(
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSending,
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
)
.id(transcriptScrollContextID)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
NewChatSwipePanInstaller(
isEnabled: canRecognizeNewChatSwipe,
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
)
}
)
}
if viewModel.showsComposer {
Divider()
.overlay(SybilTheme.border)
composerBar
}
}
}
private var customChatNavigationBar: some View {
HStack(spacing: 14) {
Button {
dismiss()
} label: {
SybilNavigationIcon(systemImage: "chevron.left")
}
.buttonStyle(.plain)
.accessibilityLabel("Back")
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
providerModelNavigationMenu
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 34)
.background(alignment: .top) {
SybilNavigationFadeBackground()
.allowsHitTesting(false)
}
}
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 {
Task {
await completeNewChatSwipe(containerWidth: containerWidth)
}
return
}
resetNewChatSwipe(animated: true)
}
@MainActor
private func completeNewChatSwipe(containerWidth: CGFloat) async {
newChatSwipeIsCompleting = true
withAnimation(.easeIn(duration: NewChatSwipeMetrics.completionAnimationDuration)) {
newChatSwipeCompletionOffset = -(containerWidth + NewChatSwipeMetrics.completionOvershoot)
}
try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs))
onRequestNewChat?()
resetNewChatSwipe(animated: false)
}
private func resetNewChatSwipe(animated: Bool) {
let reset = {
newChatSwipeOffset = 0
newChatSwipeCompletionOffset = 0
newChatSwipeIsActive = false
newChatSwipeIsCompleting = false
newChatSwipeHasLatched = false
newChatSwipeDidTriggerHaptic = false
}
if animated {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
reset()
}
} else {
reset()
}
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
}
private var header: some View {
VStack(alignment: .leading, spacing: 12) {
if let error = viewModel.errorMessage {
Text(error)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(SybilTheme.panelGradient.opacity(0.58))
}
private var providerModelToolbarMenu: some View {
providerModelMenu {
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)
)
}
}
private var providerModelNavigationMenu: some View {
providerModelMenu {
SybilNavigationIcon(systemImage: "ellipsis")
}
}
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
Menu {
providerModelMenuItems
} label: {
label()
}
.accessibilityLabel("Provider and model")
}
@ViewBuilder
private var providerModelMenuItems: some View {
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)
}
}
}
}
}
}
}
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)
)
)
}
private var composerBar: some View {
VStack(alignment: .leading, spacing: 10) {
if !viewModel.isSearchMode && !viewModel.composerAttachments.isEmpty {
SybilAttachmentListView(
attachments: viewModel.composerAttachments,
tone: .composer
) { attachmentID in
viewModel.removeComposerAttachment(id: attachmentID)
}
}
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)
)
.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 {
submitComposer()
}
.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 {
submitComposer()
} 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)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
LinearGradient(
colors: [
SybilTheme.background.opacity(0.18),
SybilTheme.background.opacity(0.96)
],
startPoint: .top,
endPoint: .bottom
)
)
.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
}
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 func submitComposer() {
guard viewModel.canSendComposer else {
return
}
#if !targetEnvironment(macCatalyst)
if !viewModel.isSearchMode {
composerFocused = false
}
#endif
Task {
await viewModel.sendComposer()
}
}
@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))
}
guard !attachments.isEmpty else {
viewModel.errorMessage = "Clipboard does not contain a supported attachment."
return
}
try viewModel.appendComposerAttachments(attachments)
composerFocused = true
} catch {
viewModel.errorMessage = error.localizedDescription
SybilLog.error(SybilLog.ui, "Clipboard attachment import failed", error: error)
}
}
}
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 let completionOvershoot: CGFloat = 180
static let completionAnimationDuration = 0.24
static let completionAnimationDelayMs: UInt64 = 240
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 SybilNavigationIcon: View {
var systemImage: String
var body: some View {
Image(systemName: systemImage)
.font(.system(size: 21, weight: .semibold, design: .rounded))
.foregroundStyle(SybilTheme.text)
.frame(width: 46, height: 46)
.contentShape(Rectangle())
.shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0)
}
}
private struct SybilNavigationFadeBackground: View {
var body: some View {
ZStack(alignment: .topLeading) {
LinearGradient(
colors: [
SybilTheme.background.opacity(1.0),
SybilTheme.background.opacity(0.90),
SybilTheme.background.opacity(0.80),
SybilTheme.background.opacity(0.80),
SybilTheme.background.opacity(0.28),
Color.clear
],
startPoint: .top,
endPoint: .bottom
)
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.36),
SybilTheme.primary.opacity(0.10),
Color.clear
],
center: .topLeading,
startRadius: 6,
endRadius: 210
)
.blendMode(.screen)
.offset(x: -44, y: -46)
}
.ignoresSafeArea(edges: .top)
}
}
private struct SybilChatCharacterBackdrop: View {
var isBusy: Bool
var body: some View {
ZStack(alignment: .topTrailing) {
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.36),
SybilTheme.primary.opacity(0.13),
Color.clear
],
center: .center,
startRadius: 10,
endRadius: 150
)
.frame(width: 136, height: 118)
.blur(radius: 7)
.offset(x: 28, y: -24)
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
.frame(width: 172, height: 172)
.opacity(0.92)
.mask {
LinearGradient(
colors: [
Color.black,
Color.black,
Color.black.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
}
.mask {
LinearGradient(
colors: [
Color.clear,
Color.black.opacity(0.74),
Color.black
],
startPoint: .leading,
endPoint: .trailing
)
}
.offset(x: 8, y: 4)
}
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
.clipped()
.ignoresSafeArea(edges: .top)
.accessibilityHidden(true)
}
}
private struct SybilAnimatedGIFView: UIViewRepresentable {
var resourceName: String
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIImageView {
let imageView = SybilAnimatedUIImageView()
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = false
imageView.isOpaque = false
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return imageView
}
func updateUIView(_ imageView: UIImageView, context: Context) {
guard context.coordinator.resourceName != resourceName else {
if !imageView.isAnimating {
imageView.startAnimating()
}
return
}
context.coordinator.resourceName = resourceName
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
imageView.startAnimating()
}
final class Coordinator {
var resourceName: String?
}
}
private final class SybilAnimatedUIImageView: UIImageView {
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
}
@MainActor
private enum SybilAnimatedGIFCache {
private static var images: [String: UIImage] = [:]
static func image(named name: String) -> UIImage? {
if let cached = images[name] {
return cached
}
guard let image = loadImage(named: name) else {
return nil
}
images[name] = image
return image
}
private static func loadImage(named name: String) -> UIImage? {
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
Bundle.main.url(forResource: name, withExtension: "gif")
guard let url,
let data = try? Data(contentsOf: url),
let source = CGImageSourceCreateWithData(data as CFData, nil)
else {
return nil
}
let frameCount = CGImageSourceGetCount(source)
guard frameCount > 0 else {
return nil
}
var frames: [UIImage] = []
frames.reserveCapacity(frameCount)
var duration: TimeInterval = 0
for index in 0 ..< frameCount {
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
continue
}
frames.append(UIImage(cgImage: cgImage))
duration += frameDuration(at: index, source: source)
}
guard !frames.isEmpty else {
return nil
}
if frames.count == 1 {
return frames[0]
}
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
}
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
else {
return 0.08
}
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
return max(delay, 0.03)
}
}
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)
}
}