import SwiftUI import Photos import UIKit private let imageExts: Set = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"] private let videoExts: Set = ["mp4","mov","m4v","avi","mkv"] enum GallerySource { case nas, local } struct GalleryView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @State private var source: GallerySource = .nas // NAS state @State private var nasItems: [NASItem] = [] @State private var nasLoading = false @State private var nasError: String? = nil // Local state @State private var localAssets: [PHAsset] = [] @State private var syncedNames: Set = [] @State private var localAuthDenied = false @State private var appeared = false private var connection: NASConnection? { store.savedConnection } private let deviceName = UIDevice.current.name private let columns = [ GridItem(.flexible(), spacing: 2), GridItem(.flexible(), spacing: 2), GridItem(.flexible(), spacing: 2) ] var body: some View { ZStack { AppTheme.background.ignoresSafeArea() switch source { case .nas: nasContent case .local: localContent } } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { sourceChip } } .task { await loadNAS() loadLocal() } .refreshable { await loadNAS() loadLocal() } } // MARK: — Source chip / dropdown private var sourceChip: some View { Menu { Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas } } label: { Label(connection?.host ?? "NAS", systemImage: "externaldrive.fill") } Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .local } } label: { Label(deviceName, systemImage: "iphone") } } label: { HStack(spacing: 5) { Circle() .fill(dotColor) .frame(width: 6, height: 6) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: dotColor == AppTheme.positive) Text(source == .nas ? (connection?.host ?? "NAS") : deviceName) .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) .lineLimit(1) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, 10) .padding(.vertical, 5) .background(AppTheme.surfaceSunken) .clipShape(Capsule(style: .continuous)) } } private var dotColor: Color { switch source { case .nas: switch lanMonitor.nasReachable { case true: return AppTheme.positive case false: return AppTheme.destructive case nil: return AppTheme.inkQuaternary } case .local: return AppTheme.interactive } } // MARK: — NAS content private var nasContent: some View { Group { if connection == nil { placeholder(icon: "externaldrive.badge.xmark", text: "No NAS connected") } else if nasLoading && nasItems.isEmpty { loadingView("Loading gallery…") } else if let err = nasError { errorView(err) { Task { await loadNAS() } } } else if nasItems.isEmpty { placeholder(icon: "photo.on.rectangle", text: "No photos backed up yet") } else { gallery(items: nasItems.map { .nas($0) }) } } } // MARK: — Local content private var localContent: some View { Group { if localAuthDenied { placeholder(icon: "lock.slash", text: "Photo library access denied") } else if localAssets.isEmpty { loadingView("Loading photos…") } else { gallery(items: localAssets.map { .local($0) }) } } } // MARK: — Shared grid private func gallery(items: [GalleryItem]) -> some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { Section { LazyVGrid(columns: columns, spacing: 2) { ForEach(Array(items.enumerated()), id: \.offset) { idx, item in GalleryCell( item: item, connection: connection, isSynced: syncStatus(for: item) ) .opacity(appeared ? 1 : 0) .animation( .spring(response: 0.35, dampingFraction: 0.82) .delay(Double(min(idx, 18)) * 0.02), value: appeared ) } } } header: { gridHeader(count: items.count) } } } .onAppear { appeared = true } } private func gridHeader(count: Int) -> some View { HStack { VStack(alignment: .leading, spacing: 2) { Text("\(count) files") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(AppTheme.ink) Group { switch source { case .nas: if let conn = connection { Text((conn.remotePath as NSString).lastPathComponent) } case .local: Text(deviceName) } } .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() if nasLoading { ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary) } } .padding(.horizontal, AppTheme.hPad) .padding(.vertical, 10) .background(AppTheme.background) } // MARK: — Sync status private func syncStatus(for item: GalleryItem) -> SyncStatus { guard source == .local else { return .none } switch item { case .local(let asset): let name = PHAssetResource.assetResources(for: asset) .first?.originalFilename.lowercased() ?? "" return syncedNames.contains(name) ? .synced : .pending case .nas: return .none } } // MARK: — Data private func loadNAS() async { guard let conn = connection else { return } nasLoading = true nasError = nil do { let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await s.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) nasItems = try await s.listDirectory(at: conn.remotePath) .filter { !$0.isDirectory } .sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) } s.disconnect() syncedNames = Set(nasItems.map { $0.name.lowercased() }) } catch let e as BackupError { nasError = e.errorDescription } catch { nasError = error.localizedDescription } nasLoading = false } private func loadLocal() { let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) guard status == .authorized || status == .limited else { if status == .denied || status == .restricted { localAuthDenied = true } PHPhotoLibrary.requestAuthorization(for: .readWrite) { granted in Task { @MainActor in if granted == .authorized || granted == .limited { loadLocalAssets() } else { localAuthDenied = true } } } return } loadLocalAssets() } private func loadLocalAssets() { let opts = PHFetchOptions() opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] let result = PHAsset.fetchAssets(with: opts) var assets: [PHAsset] = [] result.enumerateObjects { asset, _, _ in assets.append(asset) } localAssets = assets } // MARK: — Utility views private func loadingView(_ text: String) -> some View { VStack(spacing: 10) { ProgressView().tint(AppTheme.inkTertiary) Text(text).font(AppTheme.caption()).foregroundStyle(AppTheme.inkTertiary) } } private func placeholder(icon: String, text: String) -> some View { VStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 32, weight: .ultraLight)) .foregroundStyle(AppTheme.inkQuaternary) Text(text) .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) .multilineTextAlignment(.center) .padding(.horizontal, 40) } } private func errorView(_ message: String, retry: @escaping () -> Void) -> some View { VStack(spacing: 16) { placeholder(icon: "wifi.exclamationmark", text: message) Button(action: retry) { Text("Retry") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.ink) .padding(.horizontal, 16).padding(.vertical, 8) .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } .buttonStyle(ScaleButtonStyle()) } } } // MARK: — Gallery item model enum GalleryItem { case nas(NASItem) case local(PHAsset) } enum SyncStatus { case none, synced, pending } // MARK: — Cell struct GalleryCell: View { let item: GalleryItem let connection: NASConnection? let isSynced: SyncStatus @State private var image: UIImage? = nil @State private var loading = false var body: some View { ZStack(alignment: .bottomTrailing) { Group { if let img = image { Image(uiImage: img) .resizable() .scaledToFill() .transition(.opacity.animation(.easeIn(duration: 0.18))) } else { Rectangle() .fill(AppTheme.surfaceSunken) .overlay { if loading { ProgressView().scaleEffect(0.55).tint(AppTheme.inkQuaternary) } else { Image(systemName: cellIcon) .font(.system(size: 18, weight: .ultraLight)) .foregroundStyle(AppTheme.inkQuaternary) } } } } // Video badge if isVideo && image != nil { Image(systemName: "play.fill") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.white) .padding(3) .background(.black.opacity(0.45)) .clipShape(Circle()) .padding(5) } // Sync status badge (local mode only) if isSynced != .none { Circle() .fill(isSynced == .synced ? AppTheme.positive : Color(red: 1.0, green: 0.58, blue: 0.0)) .frame(width: 9, height: 9) .overlay(Circle().stroke(Color.white, lineWidth: 1.5)) .padding(5) .animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSynced == .synced) } } .aspectRatio(1, contentMode: .fit) .clipped() .task { await load() } } private var isVideo: Bool { switch item { case .nas(let n): return videoExts.contains((n.name as NSString).pathExtension.lowercased()) case .local(let a): return a.mediaType == .video } } private var cellIcon: String { switch item { case .nas(let n): let ext = (n.name as NSString).pathExtension.lowercased() return videoExts.contains(ext) ? "play.rectangle" : "photo" case .local(let a): return a.mediaType == .video ? "play.rectangle" : "photo" } } private func load() async { guard image == nil, !loading else { return } switch item { case .nas(let nasItem): guard let conn = connection else { return } let ext = (nasItem.name as NSString).pathExtension.lowercased() guard imageExts.contains(ext) || videoExts.contains(ext) else { return } if let cached = ThumbnailCache.shared.get(for: nasItem.path) { image = cached; return } loading = true do { let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await s.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) let data = try await s.downloadData(at: nasItem.path) s.disconnect() if let img = UIImage(data: data)?.thumbnailScaled(to: 300) { ThumbnailCache.shared.set(img, for: nasItem.path) image = img } } catch {} loading = false case .local(let asset): loading = true let opts = PHImageRequestOptions() opts.deliveryMode = .opportunistic opts.isNetworkAccessAllowed = true opts.isSynchronous = false let size = CGSize(width: 300, height: 300) await withCheckedContinuation { cont in PHImageManager.default().requestImage( for: asset, targetSize: size, contentMode: .aspectFill, options: opts ) { img, info in if let img { image = img } let degraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false if !degraded { cont.resume() } } } loading = false } } }