import SwiftUI import Photos import AVKit struct GalleryDetailView: View { let item: GalleryItem let allItems: [GalleryItem] let connection: NASConnection? @Environment(\.dismiss) private var dismiss @State private var currentID: String @State private var showMeta = true @State private var image: UIImage? @State private var isLoading = true @State private var scale: CGFloat = 1.0 @State private var lastScale: CGFloat = 1.0 init(item: GalleryItem, allItems: [GalleryItem], connection: NASConnection?) { self.item = item self.allItems = allItems self.connection = connection _currentID = State(initialValue: item.id) } private var current: GalleryItem { allItems.first { $0.id == currentID } ?? item } var body: some View { ZStack { Color.black.ignoresSafeArea() TabView(selection: $currentID) { ForEach(allItems) { galleryItem in DetailImageCell(item: galleryItem, connection: connection) .tag(galleryItem.id) } } .tabViewStyle(.page(indexDisplayMode: .never)) .ignoresSafeArea() .onTapGesture { withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { showMeta.toggle() } } // Top bar if showMeta { VStack { topBar Spacer() metaSheet } .transition(.opacity) } } .animation(.spring(response: 0.28, dampingFraction: 0.82), value: showMeta) } // MARK: — Top bar private var topBar: some View { HStack { Button { dismiss() } label: { ZStack { Circle() .fill(.black.opacity(0.4)) .frame(width: 32, height: 32) Image(systemName: "xmark") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) } } Spacer() syncBadge } .padding(.horizontal, AppTheme.hPad) .padding(.top, 56) } private var syncBadge: some View { let (label, color): (String, Color) = switch current.syncState { case .synced: ("Backed Up", AppTheme.positive) case .phoneOnly: ("Not Backed Up", Color(red: 1.0, green: 0.58, blue: 0.0)) case .nasOnly: ("NAS Only", AppTheme.interactive) } return HStack(spacing: 5) { Circle().fill(color).frame(width: 6, height: 6) Text(label) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white) } .padding(.horizontal, 10) .padding(.vertical, 5) .background(.black.opacity(0.4)) .clipShape(Capsule(style: .continuous)) } // MARK: — Metadata sheet private var metaSheet: some View { VStack(alignment: .leading, spacing: 0) { Capsule() .fill(Color.white.opacity(0.25)) .frame(width: 36, height: 4) .frame(maxWidth: .infinity) .padding(.top, 10) .padding(.bottom, 14) Text(current.filename) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .lineLimit(2) .padding(.horizontal, AppTheme.hPad) if let date = current.creationDate { Text(date.formatted(date: .long, time: .shortened)) .font(AppTheme.caption(13)) .foregroundStyle(.white.opacity(0.65)) .padding(.horizontal, AppTheme.hPad) .padding(.top, 2) } HStack(spacing: 16) { if let size = current.fileSize { metaChip(icon: "internaldrive", label: formatBytes(size)) } if let dur = current.duration { metaChip(icon: "play.circle", label: formatDuration(dur)) } metaChip(icon: sourceIcon, label: sourceLabel) } .padding(.horizontal, AppTheme.hPad) .padding(.top, 10) .padding(.bottom, 4) Divider().background(Color.white.opacity(0.15)).padding(.horizontal, AppTheme.hPad).padding(.vertical, 10) actionRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 32) } .background(.ultraThinMaterial.opacity(0.9)) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .padding(.horizontal, 8) .padding(.bottom, 8) .transition(.move(edge: .bottom)) } private func metaChip(icon: String, label: String) -> some View { HStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.white.opacity(0.65)) Text(label) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.85)) } } private var sourceIcon: String { switch current.source { case .phone: "iphone" case .nas: "externaldrive" case .synced: "checkmark.icloud" } } private var sourceLabel: String { switch current.source { case .phone: "Device only" case .nas: "NAS only" case .synced: "Synced" } } private var actionRow: some View { HStack(spacing: 0) { if current.source.localIdentifier != nil { actionButton(icon: "square.and.arrow.up", label: "Share") { shareCurrentItem() } } Spacer() if case .nas = current.source { actionButton(icon: "square.and.arrow.down", label: "Save") { // Store locally — needs GalleryViewModel, show a toast instead } } } } private func actionButton(icon: String, label: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 18, weight: .medium)) .foregroundStyle(.white) Text(label) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.white.opacity(0.7)) } .frame(width: 56) } .buttonStyle(ScaleButtonStyle()) } // MARK: — Helpers private func shareCurrentItem() { guard let id = current.source.localIdentifier else { return } let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil) guard let asset = result.firstObject else { return } PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, _, _, _ in guard let data, let img = UIImage(data: data) else { return } let vc = UIActivityViewController(activityItems: [img], applicationActivities: nil) guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let root = scene.windows.first?.rootViewController else { return } DispatchQueue.main.async { root.present(vc, animated: true) } } } private func formatBytes(_ bytes: Int64) -> String { let mb = Double(bytes) / 1_048_576 if mb < 1 { return "\(bytes / 1024) KB" } if mb < 1000 { return String(format: "%.1f MB", mb) } return String(format: "%.2f GB", mb / 1024) } private func formatDuration(_ s: TimeInterval) -> String { let t = Int(s) return t >= 3600 ? String(format: "%d:%02d:%02d", t / 3600, (t % 3600) / 60, t % 60) : String(format: "%d:%02d", t / 60, t % 60) } } // MARK: — Per-page image cell private struct DetailImageCell: View { let item: GalleryItem let connection: NASConnection? @State private var image: UIImage? @State private var scale: CGFloat = 1.0 @State private var offset: CGSize = .zero var body: some View { ZStack { Color.black.ignoresSafeArea() if let img = image { Image(uiImage: img) .resizable() .scaledToFit() .scaleEffect(max(scale, 1)) .offset(offset) .gesture(magnification.simultaneously(with: drag)) .onTapGesture(count: 2) { withAnimation(.spring(response: 0.3)) { scale = scale > 1.5 ? 1.0 : 2.5 if scale == 1.0 { offset = .zero } } } .transition(.opacity.animation(.easeIn(duration: 0.2))) } else { ProgressView().tint(.white.opacity(0.5)) } } .task { await loadFullImage() } } private var magnification: some Gesture { MagnificationGesture() .onChanged { scale = max(1.0, $0) } .onEnded { _ in withAnimation(.spring(response: 0.35)) { if scale < 1.2 { scale = 1.0; offset = .zero } } } } private var drag: some Gesture { DragGesture() .onChanged { if scale > 1 { offset = $0.translation } } .onEnded { _ in if scale <= 1 { withAnimation(.spring(response: 0.35)) { offset = .zero } } } } private func loadFullImage() async { switch item.source { case .phone(let id), .synced(let id, _): let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil) guard let asset = result.firstObject else { return } let opts = PHImageRequestOptions() opts.deliveryMode = .opportunistic opts.isNetworkAccessAllowed = true opts.isSynchronous = false let size = CGSize(width: 1080, height: 1080) image = await withCheckedContinuation { cont in PHImageManager.default().requestImage( for: asset, targetSize: size, contentMode: .aspectFit, options: opts ) { img, info in if let img { Task { @MainActor in self.image = img } } let degraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false if !degraded { cont.resume(returning: img) } } } case .nas(let path): image = ThumbnailCache.shared.get(for: path) } } }