diff --git a/ios/Apps/Sybil/Resources/Character/character-busy.gif b/ios/Apps/Sybil/Resources/Character/character-busy.gif new file mode 100644 index 0000000..75888bb Binary files /dev/null and b/ios/Apps/Sybil/Resources/Character/character-busy.gif differ diff --git a/ios/Apps/Sybil/Resources/Character/character-idle.gif b/ios/Apps/Sybil/Resources/Character/character-idle.gif new file mode 100644 index 0000000..7d4d544 Binary files /dev/null and b/ios/Apps/Sybil/Resources/Character/character-idle.gif differ diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index bd1f96b..e970448 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -1,3 +1,4 @@ +import ImageIO import Observation import PhotosUI import SwiftUI @@ -25,7 +26,7 @@ struct SybilWorkspaceView: View { @State private var newChatSwipeDidTriggerHaptic = false @State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator? - private let customChatNavigationContentInset: CGFloat = 88 + private let customChatNavigationContentInset: CGFloat = 96 private var isSettingsSelected: Bool { if case .settings = viewModel.selectedItem { @@ -124,6 +125,8 @@ struct SybilWorkspaceView: View { workspaceContentStack if showsCustomChatNavigation { + SybilChatCharacterBackdrop(isBusy: viewModel.isSending) + .allowsHitTesting(false) customChatNavigationBar } } @@ -980,6 +983,173 @@ private struct SybilNavigationFadeBackground: View { } } +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