import SwiftUI // MARK: - FullScreenImageViewer /// Full-screen image viewer with pinch zoom, double-tap zoom, and swipe-to-dismiss. /// /// Android parity: `ImageViewerScreen.kt` — zoom (1x–5x), double-tap (2.5x), /// vertical swipe dismiss, background fade, tap to toggle controls. struct FullScreenImageViewer: View { let image: UIImage let onDismiss: () -> Void /// Current zoom scale (1.0 = fit, up to maxScale). @State private var scale: CGFloat = 1.0 @State private var lastScale: CGFloat = 1.0 /// Pan offset when zoomed. @State private var offset: CGSize = .zero @State private var lastOffset: CGSize = .zero /// Vertical drag offset for dismiss gesture (only when not zoomed). @State private var dismissOffset: CGFloat = 0 /// Whether the UI controls (close button) are visible. @State private var showControls = true private let minScale: CGFloat = 1.0 private let maxScale: CGFloat = 5.0 private let doubleTapScale: CGFloat = 2.5 private let dismissThreshold: CGFloat = 150 var body: some View { ZStack { // Background: fades as user drags to dismiss Color.black .opacity(backgroundOpacity) .ignoresSafeArea() // Zoomable image (visual only — no gestures here) Image(uiImage: image) .resizable() .scaledToFit() .scaleEffect(scale) .offset(x: offset.width, y: offset.height + dismissOffset) .allowsHitTesting(false) // Close button (above gesture layer so it stays tappable) if showControls { VStack { HStack { Spacer() Button { onDismiss() } label: { Image(systemName: "xmark") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .frame(width: 36, height: 36) .background(Color.white.opacity(0.2)) .clipShape(Circle()) } .padding(.trailing, 16) .padding(.top, 8) } Spacer() } .transition(.opacity) } } // Gestures on the full-screen ZStack — not on the Image. // scaleEffect is visual-only and doesn't expand the Image's hit-test area, // so when zoomed to 2.5x, taps outside the original frame were lost. .contentShape(Rectangle()) .onTapGesture(count: 2) { doubleTap() } .onTapGesture(count: 1) { withAnimation(.easeInOut(duration: 0.2)) { showControls.toggle() } } .simultaneousGesture(pinchGesture) .simultaneousGesture(dragGesture) } // MARK: - Background Opacity private var backgroundOpacity: Double { let progress = min(abs(dismissOffset) / 300, 1.0) return 1.0 - progress * 0.6 } // MARK: - Double Tap Zoom private func doubleTap() { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { if scale > 1.05 { scale = 1.0 lastScale = 1.0 offset = .zero lastOffset = .zero } else { scale = doubleTapScale lastScale = doubleTapScale offset = .zero lastOffset = .zero } } } // MARK: - Pinch Gesture private var pinchGesture: some Gesture { MagnificationGesture() .onChanged { value in let newScale = lastScale * value scale = min(max(newScale, minScale * 0.5), maxScale) } .onEnded { _ in withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { if scale < minScale { scale = minScale } lastScale = scale if scale <= 1.0 { offset = .zero lastOffset = .zero } } } } // MARK: - Drag Gesture private var dragGesture: some Gesture { DragGesture() .onChanged { value in if scale > 1.05 { offset = CGSize( width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height ) } else { dismissOffset = value.translation.height } } .onEnded { _ in if scale > 1.05 { lastOffset = offset } else { if abs(dismissOffset) > dismissThreshold { onDismiss() } else { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { dismissOffset = 0 } } } } } } // MARK: - FullScreenImageFromCache /// Wrapper that loads an image from `AttachmentCache` by attachment ID and /// presents it in `FullScreenImageViewer`. Handles cache-miss gracefully. /// /// Used as `fullScreenCover` content — the attachment ID is a stable value /// passed as a parameter, avoiding @State capture issues with UIImage. struct FullScreenImageFromCache: View { let attachmentId: String let onDismiss: () -> Void @State private var image: UIImage? @State private var isLoading = true var body: some View { if let image { FullScreenImageViewer(image: image, onDismiss: onDismiss) } else { // Cache miss/loading state — show placeholder with close button. ZStack { Color.black.ignoresSafeArea() if isLoading { VStack(spacing: 16) { ProgressView() .tint(.white) Text("Loading...") .font(.system(size: 15)) .foregroundStyle(.white.opacity(0.5)) } } else { VStack(spacing: 16) { Image(systemName: "photo") .font(.system(size: 48)) .foregroundStyle(.white.opacity(0.3)) Text("Image not available") .font(.system(size: 15)) .foregroundStyle(.white.opacity(0.5)) } } VStack { HStack { Spacer() Button { onDismiss() } label: { Image(systemName: "xmark") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .frame(width: 36, height: 36) .background(Color.white.opacity(0.2)) .clipShape(Circle()) } .padding(.trailing, 16) .padding(.top, 8) } Spacer() } } .task(id: attachmentId) { if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { image = cached isLoading = false return } await ImageLoadLimiter.shared.acquire() let loaded = await Task.detached(priority: .userInitiated) { AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) }.value await ImageLoadLimiter.shared.release() guard !Task.isCancelled else { return } image = loaded isLoading = false } } } }