import SwiftUI import Photos import UIKit struct GalleryView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var statusService: BackupStatusService @StateObject private var viewModel = GalleryViewModel() @State private var selectedItem: GalleryItem? @State private var searchVisible = false private let columns = [ GridItem(.flexible(), spacing: 2), GridItem(.flexible(), spacing: 2), GridItem(.flexible(), spacing: 2) ] var body: some View { ZStack(alignment: .top) { AppTheme.background.ignoresSafeArea() VStack(spacing: 0) { tabBar if searchVisible { searchBar } mainContent } } .navigationTitle("Gallery") .navigationBarTitleDisplayMode(.inline) .toolbar { trailingItems } .fullScreenCover(item: $selectedItem) { item in GalleryDetailView( item: item, allItems: viewModel.allItems, connection: store.savedConnection ) } .task { viewModel.bind(to: statusService, connection: store.savedConnection) await viewModel.load(connection: store.savedConnection, statusService: statusService) } .refreshable { await viewModel.load(connection: store.savedConnection, statusService: statusService) } } // MARK: — Toolbar private var trailingItems: some ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { searchVisible.toggle() if !searchVisible { viewModel.searchText = "" } } } label: { Image(systemName: searchVisible ? "xmark.circle.fill" : "magnifyingglass") .font(.system(size: 16, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) } } } // MARK: — Tab bar private var tabBar: some View { Picker("", selection: $viewModel.tab) { ForEach(GalleryViewModel.Tab.allCases, id: \.self) { tab in Text(tab.rawValue).tag(tab) } } .pickerStyle(.segmented) .padding(.horizontal, AppTheme.hPad) .padding(.vertical, 8) } // MARK: — Search private var searchBar: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .font(.system(size: 13)) .foregroundStyle(AppTheme.inkTertiary) TextField("Search filenames", text: $viewModel.searchText) .font(AppTheme.body(14)) .autocorrectionDisabled() .textInputAutocapitalization(.never) if !viewModel.searchText.isEmpty { Button { viewModel.searchText = "" } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 13)) .foregroundStyle(AppTheme.inkTertiary) } } } .padding(.horizontal, 10) .padding(.vertical, 7) .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 6) .transition(.move(edge: .top).combined(with: .opacity)) } // MARK: — Content states @ViewBuilder private var mainContent: some View { if viewModel.isLoading && viewModel.sections.isEmpty { Spacer() VStack(spacing: 12) { ProgressView().tint(AppTheme.inkTertiary) Text("Loading gallery…") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() } else if viewModel.sections.isEmpty { Spacer() VStack(spacing: 12) { Image(systemName: emptyIcon) .font(.system(size: 38, weight: .ultraLight)) .foregroundStyle(AppTheme.inkQuaternary) Text(emptyMessage) .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) .multilineTextAlignment(.center) .padding(.horizontal, 40) } Spacer() } else { photoGrid } } private var emptyIcon: String { switch viewModel.tab { case .all, .phone: "photo.on.rectangle.angled" case .nas: "externaldrive.badge.questionmark" } } private var emptyMessage: String { let q = viewModel.searchText.trimmingCharacters(in: .whitespaces) if !q.isEmpty { return "No results for \"\(q)\"" } switch viewModel.tab { case .all: return "No photos yet" case .phone: return "No photos on this device" case .nas: return "No backed-up files found" } } // MARK: — Grid private var photoGrid: some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(viewModel.sections) { section in Section { LazyVGrid(columns: columns, spacing: 2) { ForEach(section.items) { item in GalleryCell(item: item, connection: store.savedConnection) .contentShape(Rectangle()) .onTapGesture { selectedItem = item } .contextMenu { itemContextMenu(item) } } } } header: { sectionHeader(for: section) } } } } } private func sectionHeader(for section: GallerySection) -> some View { HStack(spacing: 6) { Text(section.title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(AppTheme.ink) Spacer() Text("\(section.items.count)") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } .padding(.horizontal, AppTheme.hPad) .padding(.vertical, 7) .background(.ultraThinMaterial) } // MARK: — Context menu @ViewBuilder private func itemContextMenu(_ item: GalleryItem) -> some View { Button { selectedItem = item } label: { Label("View", systemImage: "eye") } if item.source.localIdentifier != nil { Button { sharePhoneAsset(item) } label: { Label("Share", systemImage: "square.and.arrow.up") } } if case .nas = item.source { Button { Task { await viewModel.storeLocally(item: item, connection: store.savedConnection) } } label: { Label("Save to Photos", systemImage: "square.and.arrow.down") } } Divider() if item.source.localIdentifier != nil { Button(role: .destructive) { deleteFromDevice(item) } label: { Label("Delete from Device", systemImage: "trash") } } } private func sharePhoneAsset(_ item: GalleryItem) { guard let id = item.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 deleteFromDevice(_ item: GalleryItem) { guard let id = item.source.localIdentifier else { return } let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil) guard result.count > 0 else { return } PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(result) } completionHandler: { success, _ in if success { Task { @MainActor in await viewModel.load(connection: store.savedConnection, statusService: statusService) } } } } } // MARK: — Cell struct GalleryCell: View { let item: GalleryItem let connection: NASConnection? @State private var image: UIImage? @State private var taskID = UUID() var body: some View { // Color.clear anchors the 1:1 square; .overlay constrains all children to that // exact frame so scaledToFill never inflates the cell height in LazyVGrid. Color.clear .aspectRatio(1, contentMode: .fit) .overlay { if let img = image { Image(uiImage: img) .resizable() .scaledToFill() .transition(.opacity.animation(.easeIn(duration: 0.18))) } else { Rectangle() .fill(AppTheme.surfaceSunken) .shimmer() } } .overlay(alignment: .bottomLeading) { if item.mediaType == .video, image != nil { videoOverlay } } .overlay(alignment: .bottomTrailing) { syncDot } .clipped() .id(taskID) .task(id: taskID) { await loadThumbnail() } } private var videoOverlay: some View { HStack(spacing: 3) { Image(systemName: "play.fill") .font(.system(size: 8, weight: .bold)) .foregroundStyle(.white) if let dur = item.duration { Text(formatDuration(dur)) .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.white) } } .padding(.horizontal, 5) .padding(.vertical, 3) .background(.black.opacity(0.55)) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .padding(5) } private var syncDot: some View { let color: Color = switch item.syncState { case .synced: AppTheme.positive case .phoneOnly: Color(red: 1.0, green: 0.58, blue: 0.0) case .nasOnly: AppTheme.interactive } return Circle() .fill(color) .frame(width: 8, height: 8) .overlay(Circle().stroke(Color.white, lineWidth: 1.5)) .padding(5) } 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: — Thumbnail loading private func loadThumbnail() async { guard image == nil else { return } switch item.source { case .phone(let id), .synced(let id, _): image = await loadPhoneImage(localID: id) case .nas(let path): image = await loadNASImage(path: path) } } private func loadPhoneImage(localID: String) async -> UIImage? { let result = PHAsset.fetchAssets(withLocalIdentifiers: [localID], options: nil) guard let asset = result.firstObject else { return nil } let opts = PHImageRequestOptions() opts.deliveryMode = .fastFormat opts.isNetworkAccessAllowed = false opts.isSynchronous = false return await withCheckedContinuation { cont in PHImageManager.default().requestImage( for: asset, targetSize: CGSize(width: 300, height: 300), contentMode: .aspectFill, options: opts ) { img, _ in cont.resume(returning: img) } } } private func loadNASImage(path: String) async -> UIImage? { if let cached = ThumbnailCache.shared.get(for: path) { return cached } guard let conn = connection else { return nil } do { let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await transfer.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) let data = try await transfer.downloadData(at: path) transfer.disconnect() if let img = await ThumbnailCache.decodeOffThread(data: data, size: 300) { ThumbnailCache.shared.set(img, for: path) return img } } catch {} return nil } } // MARK: — Shimmer private struct ShimmerModifier: ViewModifier { @State private var phase: CGFloat = -1 func body(content: Content) -> some View { content.overlay( GeometryReader { geo in LinearGradient( colors: [.clear, Color.white.opacity(0.25), .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: geo.size.width * 2) .offset(x: phase * geo.size.width * 2) .animation( .linear(duration: 1.4).repeatForever(autoreverses: false), value: phase ) } .clipped() ) .onAppear { phase = 1 } } } extension View { func shimmer() -> some View { modifier(ShimmerModifier()) } }