From 025326f2b29e582e5a267211e4521665c2e64fd4 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 17 May 2026 21:30:31 +0300 Subject: [PATCH] feat: redesign Gallery as photo timeline with sync state, deduplication, and detail view - GalleryItem: unified model with phone/nas/synced source, sync state enum - GalleryViewModel: off-actor PHFetchResult + manifest merge, Phone/NAS/All tab filtering, date section grouping, Combine-driven reactivity - GalleryView: sticky date section headers, 3-col grid, shimmer placeholders, sync dots (green/orange/blue), video duration badge, search bar, context menu (Share, Save to Photos, Delete from Device) - GalleryDetailView: full-screen swipeable photo viewer (TabView page mode), pinch-to-zoom, double-tap zoom toggle, bottom metadata sheet with file info and actions - BackupStatusService: expose lastManifest for gallery deduplication without extra NAS connection - ThumbnailCache: add decodeOffThread static helper for off-actor JPEG decode - BackupManifest: Sendable conformance for safe Task.detached capture - ThumbnailCache: synced items load from PHImageManager (instant) instead of NAS Co-Authored-By: Kutesir Co-Authored-By: Sentry --- Core/Models/BackupManifest.swift | 2 +- Core/Models/GalleryItem.swift | 47 ++ Features/Browse/GalleryDetailView.swift | 319 ++++++++++++ Features/Browse/GalleryView.swift | 661 ++++++++++++------------ Features/Browse/GalleryViewModel.swift | 227 ++++++++ Kisani.xcodeproj/project.pbxproj | 12 + Services/BackupStatusService.swift | 10 +- Services/ThumbnailCache.swift | 8 + 8 files changed, 939 insertions(+), 347 deletions(-) create mode 100644 Core/Models/GalleryItem.swift create mode 100644 Features/Browse/GalleryDetailView.swift create mode 100644 Features/Browse/GalleryViewModel.swift diff --git a/Core/Models/BackupManifest.swift b/Core/Models/BackupManifest.swift index 034802a..7bf5cfd 100644 --- a/Core/Models/BackupManifest.swift +++ b/Core/Models/BackupManifest.swift @@ -60,7 +60,7 @@ struct ManifestEntry: Codable, Sendable { let uploadedAt: Date } -struct BackupManifest: Codable { +struct BackupManifest: Codable, Sendable { static let remoteFilename = ".kisani.json" var version: Int = 1 diff --git a/Core/Models/GalleryItem.swift b/Core/Models/GalleryItem.swift new file mode 100644 index 0000000..95a3373 --- /dev/null +++ b/Core/Models/GalleryItem.swift @@ -0,0 +1,47 @@ +import Foundation + +struct GalleryItem: Identifiable, Sendable { + let id: String + let filename: String + let creationDate: Date? + let duration: TimeInterval? // non-nil for video + let fileSize: Int64? + let mediaType: MediaType + let source: ItemSource + + enum MediaType: Sendable { case photo, video, unknown } + + enum ItemSource: Sendable { + case phone(localIdentifier: String) + case nas(path: String) + case synced(localIdentifier: String, nasPath: String) + + var localIdentifier: String? { + switch self { case .phone(let id), .synced(let id, _): id; case .nas: nil } + } + var nasPath: String? { + switch self { case .nas(let p), .synced(_, let p): p; case .phone: nil } + } + } + + enum SyncState: Sendable { + case phoneOnly // orange — backed up soon + case nasOnly // blue — on NAS, not this device + case synced // green — confirmed both places + } + + var syncState: SyncState { + switch source { + case .phone: .phoneOnly + case .nas: .nasOnly + case .synced: .synced + } + } +} + +struct GallerySection: Identifiable { + var id: String // "2026-05-17" or "unknown" + var date: Date + var title: String + var items: [GalleryItem] +} diff --git a/Features/Browse/GalleryDetailView.swift b/Features/Browse/GalleryDetailView.swift new file mode 100644 index 0000000..b363754 --- /dev/null +++ b/Features/Browse/GalleryDetailView.swift @@ -0,0 +1,319 @@ +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) + } + } +} diff --git a/Features/Browse/GalleryView.swift b/Features/Browse/GalleryView.swift index 5574c6e..06db221 100644 --- a/Features/Browse/GalleryView.swift +++ b/Features/Browse/GalleryView.swift @@ -2,31 +2,13 @@ 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 + @EnvironmentObject var statusService: BackupStatusService + @StateObject private var viewModel = GalleryViewModel() - @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 + @State private var selectedItem: GalleryItem? + @State private var searchVisible = false private let columns = [ GridItem(.flexible(), spacing: 2), @@ -35,395 +17,388 @@ struct GalleryView: View { ] var body: some View { - ZStack { + ZStack(alignment: .top) { AppTheme.background.ignoresSafeArea() - - switch source { - case .nas: nasContent - case .local: localContent + VStack(spacing: 0) { + tabBar + if searchVisible { searchBar } + mainContent } } - .navigationTitle("") + .navigationTitle("Gallery") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { sourceChip } + .toolbar { trailingItems } + .fullScreenCover(item: $selectedItem) { item in + GalleryDetailView( + item: item, + allItems: viewModel.allItems, + connection: store.savedConnection + ) } .task { - await loadNAS() - loadLocal() + await viewModel.load(connection: store.savedConnection, statusService: statusService) } .refreshable { - await loadNAS() - loadLocal() + await viewModel.load(connection: store.savedConnection, statusService: statusService) } } - // MARK: — Source chip / dropdown + // MARK: — Toolbar - private var sourceChip: some View { - Menu { + private var trailingItems: some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas } + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + searchVisible.toggle() + if !searchVisible { viewModel.searchText = "" } + } } 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)) + Image(systemName: searchVisible ? "xmark.circle.fill" : "magnifyingglass") + .font(.system(size: 16, 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 + // MARK: — Tab bar - 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) }) + 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: — Shared grid + // MARK: — Search - 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()) + 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() - if nasLoading { - ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary) + } 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) } - } - .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 + Spacer() + } else { + photoGrid } } - // 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 + private var emptyIcon: String { + switch viewModel.tab { + case .all, .phone: "photo.on.rectangle.angled" + case .nas: "externaldrive.badge.questionmark" } - 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 + 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) } } } - 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()) + 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) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) } + .padding(.horizontal, AppTheme.hPad) + .padding(.vertical, 7) + .background(.ultraThinMaterial) } - 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)) + // 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) + } } - .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 + @State private var image: UIImage? + @State private var taskID = UUID() 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) + ZStack { + cellBackground + if item.mediaType == .video, image != nil { + videoOverlay.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) } + syncDot.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } .aspectRatio(1, contentMode: .fit) .clipped() - .task { await load() } + .id(taskID) + .task(id: taskID) { await loadThumbnail() } } - 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 + @ViewBuilder + private var cellBackground: some View { + if let img = image { + Image(uiImage: img) + .resizable() + .scaledToFill() + .transition(.opacity.animation(.easeIn(duration: 0.18))) + } else { + Rectangle() + .fill(AppTheme.surfaceSunken) + .shimmer() } } - 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() } - } + 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) } - loading = false } + .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()) } +} diff --git a/Features/Browse/GalleryViewModel.swift b/Features/Browse/GalleryViewModel.swift new file mode 100644 index 0000000..dbe05f3 --- /dev/null +++ b/Features/Browse/GalleryViewModel.swift @@ -0,0 +1,227 @@ +import Foundation +import Photos +import UIKit +import Combine + +private let imageExts: Set = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"] +private let videoExts: Set = ["mp4","mov","m4v","avi","mkv"] + +@MainActor +final class GalleryViewModel: ObservableObject { + enum Tab: String, CaseIterable { case all = "All", phone = "Phone", nas = "NAS" } + + @Published var tab: Tab = .all + @Published var searchText = "" + @Published private(set) var sections: [GallerySection] = [] + @Published private(set) var isLoading = false + @Published private(set) var nasError: String? + + private(set) var allItems: [GalleryItem] = [] + private var loadTask: Task? + private var cancellables = Set() + + init() { + $tab + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.recomputeSections() } + .store(in: &cancellables) + $searchText + .dropFirst() + .debounce(for: .milliseconds(150), scheduler: RunLoop.main) + .sink { [weak self] _ in self?.recomputeSections() } + .store(in: &cancellables) + } + + func load(connection: NASConnection?, statusService: BackupStatusService) async { + loadTask?.cancel() + isLoading = true + nasError = nil + + let manifestEntries = statusService.lastManifest?.entries ?? [] + + let phoneAssets = await Task.detached(priority: .userInitiated) { + Self.fetchPhoneAssets() + }.value + + let items = await Task.detached(priority: .userInitiated) { + Self.buildItems(phone: phoneAssets, manifest: manifestEntries) + }.value + + allItems = items + recomputeSections() + isLoading = false + } + + func recomputeSections() { + let filtered = filteredItems() + sections = buildSections(from: filtered) + } + + // MARK: — Static off-actor workers + + private nonisolated static func fetchPhoneAssets() + -> [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)] + { + let opts = PHFetchOptions() + opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + opts.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared, .typeiTunesSynced] + let result = PHAsset.fetchAssets(with: opts) + var out: [(String, String, Date?, TimeInterval?, Bool)] = [] + out.reserveCapacity(result.count) + result.enumerateObjects { asset, _, _ in + let resources = PHAssetResource.assetResources(for: asset) + let filename = resources.first?.originalFilename + ?? "\(asset.localIdentifier.prefix(8)).jpg" + let isVideo = asset.mediaType == .video + out.append(( + asset.localIdentifier, + filename, + asset.creationDate, + isVideo ? asset.duration : nil, + isVideo + )) + } + return out + } + + private nonisolated static func buildItems( + phone: [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)], + manifest: [ManifestEntry] + ) -> [GalleryItem] { + var byID = [String: ManifestEntry](minimumCapacity: manifest.count) + var byName = [String: ManifestEntry](minimumCapacity: manifest.count) + for entry in manifest { + if !entry.localIdentifier.isEmpty { byID[entry.localIdentifier] = entry } + let key = entry.filename.lowercased() + if byName[key] == nil { byName[key] = entry } + } + + var result: [GalleryItem] = [] + result.reserveCapacity(phone.count + manifest.count / 4) + var coveredFilenames = Set() + + for (localID, filename, date, duration, isVideo) in phone { + let key = filename.lowercased() + if let entry = byID[localID] ?? byName[key] { + coveredFilenames.insert(entry.filename.lowercased()) + result.append(GalleryItem( + id: "synced_\(localID)", + filename: filename, + creationDate: date, + duration: duration, + fileSize: entry.fileSize > 0 ? entry.fileSize : nil, + mediaType: isVideo ? .video : .photo, + source: .synced(localIdentifier: localID, nasPath: entry.remotePath) + )) + } else { + result.append(GalleryItem( + id: "phone_\(localID)", + filename: filename, + creationDate: date, + duration: duration, + fileSize: nil, + mediaType: isVideo ? .video : .photo, + source: .phone(localIdentifier: localID) + )) + } + } + + // NAS-only: in manifest but not matched to a phone asset + for entry in manifest { + guard !coveredFilenames.contains(entry.filename.lowercased()) else { continue } + let ext = (entry.filename as NSString).pathExtension.lowercased() + let mediaType: GalleryItem.MediaType = + videoExts.contains(ext) ? .video : imageExts.contains(ext) ? .photo : .unknown + result.append(GalleryItem( + id: "nas_\(entry.remotePath)", + filename: entry.filename, + creationDate: entry.creationDate, + duration: nil, + fileSize: entry.fileSize > 0 ? entry.fileSize : nil, + mediaType: mediaType, + source: .nas(path: entry.remotePath) + )) + } + + result.sort { ($0.creationDate ?? .distantPast) > ($1.creationDate ?? .distantPast) } + return result + } + + // MARK: — Filtering + grouping + + private func filteredItems() -> [GalleryItem] { + var base: [GalleryItem] + switch tab { + case .all: base = allItems + case .phone: base = allItems.filter { if case .nas = $0.source { return false }; return true } + case .nas: base = allItems.filter { if case .phone = $0.source { return false }; return true } + } + let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() + if !q.isEmpty { base = base.filter { $0.filename.lowercased().contains(q) } } + return base + } + + private func buildSections(from items: [GalleryItem]) -> [GallerySection] { + let cal = Calendar.current + let currentYear = cal.component(.year, from: Date()) + var sections: [GallerySection] = [] + var indexMap: [String: Int] = [:] + + for item in items { + let date = item.creationDate ?? .distantPast + let sectionID: String + let title: String + + if item.creationDate == nil { + sectionID = "unknown" + title = "Unknown Date" + } else { + let comps = cal.dateComponents([.year, .month, .day], from: date) + sectionID = String(format: "%04d-%02d-%02d", + comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) + let year = comps.year ?? currentYear + if year == currentYear { + title = date.formatted(.dateTime.month(.wide).day()) + } else { + title = date.formatted(.dateTime.month(.wide).day().year()) + } + } + + if let idx = indexMap[sectionID] { + sections[idx].items.append(item) + } else { + indexMap[sectionID] = sections.count + sections.append(GallerySection(id: sectionID, date: date, title: title, items: [item])) + } + } + return sections + } + + // MARK: — Actions + + func storeLocally(item: GalleryItem, connection: NASConnection?) async { + guard case .nas(let path) = item.source, let conn = connection else { return } + 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() + + let ext = (item.filename as NSString).pathExtension.lowercased() + let isVideo = videoExts.contains(ext) + + try await PHPhotoLibrary.shared().performChanges { + if isVideo { + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent(item.filename) + try? data.write(to: tmpURL) + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tmpURL) + } else if let img = UIImage(data: data) { + PHAssetChangeRequest.creationRequestForAsset(from: img) + } + } + } catch {} + } +} diff --git a/Kisani.xcodeproj/project.pbxproj b/Kisani.xcodeproj/project.pbxproj index e1ab870..0438d39 100644 --- a/Kisani.xcodeproj/project.pbxproj +++ b/Kisani.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 19CB72B2DCA08EC8C8EB3103 /* UGreenCompatibilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B4A0DF226AB4C5EE9E0D16 /* UGreenCompatibilityBadge.swift */; }; 1AEF3190A6F5D3AB43CC521D /* BackupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A5F6ABBD052F931949B2FA /* BackupEngineTests.swift */; }; 20F9E76FDA60BA806980E44A /* ToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674029116718AB4525AD9450 /* ToggleRow.swift */; }; + 218313D06E536CC541620B1A /* GalleryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18ADB8B4D428026420CCA46B /* GalleryDetailView.swift */; }; 24B0F2FC039AF5898BF3ADAE /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57448AD7D80B10985B8A222B /* Date+.swift */; }; 280D31F6A61529FB13A06EA4 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = BD0A57EC048DFE706E440152 /* SMBClient */; }; 28C259C9EB7F49F4C2043375 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4F11D9683F64A06B855649 /* SMBService.swift */; }; @@ -34,6 +35,7 @@ 48A1C445346B6E84BB4955D3 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E987653F4F7129568656EFDE /* BackupResult.swift */; }; 526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA5C090CF766ED8E3AF77D1 /* KeychainStore.swift */; }; 52D01C4B07CBCEB957E6A48D /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF29317285580DD2246F54F /* ThumbnailCache.swift */; }; + 54744C00729FAAB9715B06B5 /* GalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409404E5F90E8FECE829E43D /* GalleryViewModel.swift */; }; 5861495375343105B577CFEF /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371184164F77DD3B847A75C7 /* SettingsView.swift */; }; 600B0282353CDE47AA5B9069 /* NASProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8635AB32FC433B6ECA98B2D /* NASProtocolTests.swift */; }; 6328A1448CA2F792C509AFC6 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A63232F3A6456E16E36215 /* BackupView.swift */; }; @@ -59,6 +61,7 @@ D18F0F1DCFEB945F2AD6DC20 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C29AB7B029DBC37E189C81D6 /* ConnectView.swift */; }; D2EB2844309ECB331955F2A9 /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF029B56DE72F2D5D7977D95 /* SFTPService.swift */; }; D7C3444EF261573E590725E9 /* MockNASService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B1138B91494A6B3BF4515C0 /* MockNASService.swift */; }; + D8247C4721601698EB006C86 /* GalleryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE62B5210F46415531D531D /* GalleryItem.swift */; }; E6E8A98506D79BB36771FA16 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB509370FEEE38EA8732FDE /* GalleryView.swift */; }; EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */; }; EE8AE9228AC7BA87E0985BDF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */; }; @@ -81,6 +84,7 @@ 0B68D1C6752070B3B79B20F9 /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = ""; }; 101678CDEDFF343DCC880C2F /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = ""; }; 1088EF7C38547B982CA1F220 /* LANMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitorTests.swift; sourceTree = ""; }; + 18ADB8B4D428026420CCA46B /* GalleryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetailView.swift; sourceTree = ""; }; 1AB509370FEEE38EA8732FDE /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = ""; }; 1AF29317285580DD2246F54F /* ThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = ""; }; 22BF6D1FB493F75096805690 /* FolderSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSetupView.swift; sourceTree = ""; }; @@ -95,6 +99,7 @@ 3CD95417C6B22E6FB7BECC4D /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = ""; }; 3E4F11D9683F64A06B855649 /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = ""; }; 3F26ACA8ED4D03984F5A9C7D /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; + 409404E5F90E8FECE829E43D /* GalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryViewModel.swift; sourceTree = ""; }; 4512EE2A2DE276DB46690E06 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; }; 57448AD7D80B10985B8A222B /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = ""; }; 5AA5C090CF766ED8E3AF77D1 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; @@ -118,6 +123,7 @@ C29AB7B029DBC37E189C81D6 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; C2FABB55A4E874EE203CE585 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = ""; }; C5FFBBC0D0A57B7476BAFBCF /* PhotoPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPermissionView.swift; sourceTree = ""; }; + CEE62B5210F46415531D531D /* GalleryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryItem.swift; sourceTree = ""; }; D008AD85FA9B690DAFECCC34 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = ""; }; D8635AB32FC433B6ECA98B2D /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = ""; }; DA38966CDE15FCE36BEA5DC5 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = ""; }; @@ -240,6 +246,7 @@ 966927456571CE45600BEF75 /* BackupManifest.swift */, E987653F4F7129568656EFDE /* BackupResult.swift */, 2EDEAE39E0AFC06D0C169589 /* BackupStatusSnapshot.swift */, + CEE62B5210F46415531D531D /* GalleryItem.swift */, DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */, ); path = Models; @@ -323,7 +330,9 @@ isa = PBXGroup; children = ( 6004FE7B107CC60894EDC434 /* BrowseView.swift */, + 18ADB8B4D428026420CCA46B /* GalleryDetailView.swift */, 1AB509370FEEE38EA8732FDE /* GalleryView.swift */, + 409404E5F90E8FECE829E43D /* GalleryViewModel.swift */, ); path = Browse; sourceTree = ""; @@ -513,7 +522,10 @@ 24B0F2FC039AF5898BF3ADAE /* Date+.swift in Sources */, 3165724C02C31367688DC9A1 /* FolderRow.swift in Sources */, FFC6FD2A10B12C5536CC8BFF /* FolderSetupView.swift in Sources */, + 218313D06E536CC541620B1A /* GalleryDetailView.swift in Sources */, + D8247C4721601698EB006C86 /* GalleryItem.swift in Sources */, E6E8A98506D79BB36771FA16 /* GalleryView.swift in Sources */, + 54744C00729FAAB9715B06B5 /* GalleryViewModel.swift in Sources */, 0FCDDB2EAE2937413C4290BC /* HistoryView.swift in Sources */, 526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */, EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */, diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index ec8d294..3b5fb2f 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -13,6 +13,7 @@ final class BackupStatusService: NSObject, ObservableObject { @Published private(set) var snapshot: BackupStatusSnapshot = .empty @Published private(set) var isRefreshing: Bool = false @Published private(set) var refreshError: String? + @Published private(set) var lastManifest: BackupManifest? private let photoService = PhotoLibraryService() private let cacheKey = "backupStatusSnapshot_v2" @@ -204,12 +205,15 @@ final class BackupStatusService: NSObject, ObservableObject { do { let data = try await transfer.downloadData(at: path) // Decode and build index off main actor — JSONDecoder is not cheap for large manifests. - let index: ManifestIndex? = await Task.detached(priority: .utility) { + let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) { guard let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) else { return nil } - return ManifestIndex(manifest: manifest) + return (ManifestIndex(manifest: manifest), manifest) }.value - if let index { return index } + if let (index, manifest) = decoded { + lastManifest = manifest + return index + } } catch { // File not found — fall through to directory listing bootstrap } diff --git a/Services/ThumbnailCache.swift b/Services/ThumbnailCache.swift index b2f5e10..43ee0ea 100644 --- a/Services/ThumbnailCache.swift +++ b/Services/ThumbnailCache.swift @@ -39,6 +39,14 @@ final class ThumbnailCache { } } +extension ThumbnailCache { + static func decodeOffThread(data: Data, size: CGFloat) async -> UIImage? { + await Task.detached(priority: .userInitiated) { + UIImage(data: data)?.thumbnailScaled(to: size) + }.value + } +} + extension UIImage { func thumbnailScaled(to size: CGFloat) -> UIImage { let scale = min(size / self.size.width, size / self.size.height)