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 <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 21:30:31 +03:00
parent 5b68fde62f
commit 025326f2b2
8 changed files with 939 additions and 347 deletions

View File

@@ -60,7 +60,7 @@ struct ManifestEntry: Codable, Sendable {
let uploadedAt: Date let uploadedAt: Date
} }
struct BackupManifest: Codable { struct BackupManifest: Codable, Sendable {
static let remoteFilename = ".kisani.json" static let remoteFilename = ".kisani.json"
var version: Int = 1 var version: Int = 1

View File

@@ -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]
}

View File

@@ -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)
}
}
}

View File

@@ -2,31 +2,13 @@ import SwiftUI
import Photos import Photos
import UIKit import UIKit
private let imageExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"]
private let videoExts: Set<String> = ["mp4","mov","m4v","avi","mkv"]
enum GallerySource { case nas, local }
struct GalleryView: View { struct GalleryView: View {
@EnvironmentObject var store: ConnectionStore @EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor @EnvironmentObject var statusService: BackupStatusService
@StateObject private var viewModel = GalleryViewModel()
@State private var source: GallerySource = .nas @State private var selectedItem: GalleryItem?
@State private var searchVisible = false
// 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<String> = []
@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 = [ private let columns = [
GridItem(.flexible(), spacing: 2), GridItem(.flexible(), spacing: 2),
@@ -35,291 +17,263 @@ struct GalleryView: View {
] ]
var body: some View { var body: some View {
ZStack { ZStack(alignment: .top) {
AppTheme.background.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
switch source { tabBar
case .nas: nasContent if searchVisible { searchBar }
case .local: localContent mainContent
} }
} }
.navigationTitle("") .navigationTitle("Gallery")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar { trailingItems }
ToolbarItem(placement: .navigationBarTrailing) { sourceChip } .fullScreenCover(item: $selectedItem) { item in
GalleryDetailView(
item: item,
allItems: viewModel.allItems,
connection: store.savedConnection
)
} }
.task { .task {
await loadNAS() await viewModel.load(connection: store.savedConnection, statusService: statusService)
loadLocal()
} }
.refreshable { .refreshable {
await loadNAS() await viewModel.load(connection: store.savedConnection, statusService: statusService)
loadLocal()
} }
} }
// MARK: Source chip / dropdown // MARK: Toolbar
private var sourceChip: some View { private var trailingItems: some ToolbarContent {
Menu { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas } withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
} label: { searchVisible.toggle()
Label(connection?.host ?? "NAS", systemImage: "externaldrive.fill") if !searchVisible { viewModel.searchText = "" }
}
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .local }
} label: {
Label(deviceName, systemImage: "iphone")
} }
} label: { } label: {
HStack(spacing: 5) { Image(systemName: searchVisible ? "xmark.circle.fill" : "magnifyingglass")
Circle() .font(.system(size: 16, weight: .medium))
.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) .foregroundStyle(AppTheme.inkSecondary)
.lineLimit(1) }
Image(systemName: "chevron.down") }
.font(.system(size: 9, weight: .semibold)) }
.foregroundStyle(AppTheme.inkQuaternary)
// 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(.horizontal, 10)
.padding(.vertical, 5) .padding(.vertical, 7)
.background(AppTheme.surfaceSunken) .background(AppTheme.surfaceSunken)
.clipShape(Capsule(style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 6)
.transition(.move(edge: .top).combined(with: .opacity))
} }
private var dotColor: Color { // MARK: Content states
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 @ViewBuilder
private var mainContent: some View {
private var nasContent: some View { if viewModel.isLoading && viewModel.sections.isEmpty {
Group { Spacer()
if connection == nil { VStack(spacing: 12) {
placeholder(icon: "externaldrive.badge.xmark", text: "No NAS connected") ProgressView().tint(AppTheme.inkTertiary)
} else if nasLoading && nasItems.isEmpty { Text("Loading gallery…")
loadingView("Loading gallery…") .font(AppTheme.caption())
} 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) .foregroundStyle(AppTheme.inkTertiary)
} }
Spacer() Spacer()
if nasLoading { } else if viewModel.sections.isEmpty {
ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary) Spacer()
}
}
.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) { VStack(spacing: 12) {
Image(systemName: icon) Image(systemName: emptyIcon)
.font(.system(size: 32, weight: .ultraLight)) .font(.system(size: 38, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
Text(text) Text(emptyMessage)
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 40) .padding(.horizontal, 40)
} }
Spacer()
} else {
photoGrid
}
} }
private func errorView(_ message: String, retry: @escaping () -> Void) -> some View { private var emptyIcon: String {
VStack(spacing: 16) { switch viewModel.tab {
placeholder(icon: "wifi.exclamationmark", text: message) case .all, .phone: "photo.on.rectangle.angled"
Button(action: retry) { case .nas: "externaldrive.badge.questionmark"
Text("Retry") }
.font(.system(size: 13, weight: .medium)) }
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) .foregroundStyle(AppTheme.ink)
.padding(.horizontal, 16).padding(.vertical, 8) Spacer()
.background(AppTheme.surfaceSunken) Text("\(section.items.count)")
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
} }
.buttonStyle(ScaleButtonStyle()) .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")
} }
} }
} }
// MARK: Gallery item model private func sharePhoneAsset(_ item: GalleryItem) {
guard let id = item.source.localIdentifier else { return }
enum GalleryItem { let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil)
case nas(NASItem) guard let asset = result.firstObject else { return }
case local(PHAsset) 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) }
}
} }
enum SyncStatus { case none, synced, pending } 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 // MARK: Cell
struct GalleryCell: View { struct GalleryCell: View {
let item: GalleryItem let item: GalleryItem
let connection: NASConnection? let connection: NASConnection?
let isSynced: SyncStatus
@State private var image: UIImage? = nil @State private var image: UIImage?
@State private var loading = false @State private var taskID = UUID()
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack {
Group { 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()
.id(taskID)
.task(id: taskID) { await loadThumbnail() }
}
@ViewBuilder
private var cellBackground: some View {
if let img = image { if let img = image {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
@@ -328,102 +282,123 @@ struct GalleryCell: View {
} else { } else {
Rectangle() Rectangle()
.fill(AppTheme.surfaceSunken) .fill(AppTheme.surfaceSunken)
.overlay { .shimmer()
if loading {
ProgressView().scaleEffect(0.55).tint(AppTheme.inkQuaternary)
} else {
Image(systemName: cellIcon)
.font(.system(size: 18, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
} }
} }
// Video badge private var videoOverlay: some View {
if isVideo && image != nil { HStack(spacing: 3) {
Image(systemName: "play.fill") Image(systemName: "play.fill")
.font(.system(size: 9, weight: .semibold)) .font(.system(size: 8, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(3) if let dur = item.duration {
.background(.black.opacity(0.45)) Text(formatDuration(dur))
.clipShape(Circle()) .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) .padding(5)
} }
// Sync status badge (local mode only) private var syncDot: some View {
if isSynced != .none { let color: Color = switch item.syncState {
Circle() case .synced: AppTheme.positive
.fill(isSynced == .synced ? AppTheme.positive : Color(red: 1.0, green: 0.58, blue: 0.0)) case .phoneOnly: Color(red: 1.0, green: 0.58, blue: 0.0)
.frame(width: 9, height: 9) case .nasOnly: AppTheme.interactive
}
return Circle()
.fill(color)
.frame(width: 8, height: 8)
.overlay(Circle().stroke(Color.white, lineWidth: 1.5)) .overlay(Circle().stroke(Color.white, lineWidth: 1.5))
.padding(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 { private func formatDuration(_ s: TimeInterval) -> String {
switch item { let t = Int(s)
case .nas(let n): return videoExts.contains((n.name as NSString).pathExtension.lowercased()) return t >= 3600
case .local(let a): return a.mediaType == .video ? 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 var cellIcon: String { private func loadPhoneImage(localID: String) async -> UIImage? {
switch item { let result = PHAsset.fetchAssets(withLocalIdentifiers: [localID], options: nil)
case .nas(let n): guard let asset = result.firstObject else { return nil }
let ext = (n.name as NSString).pathExtension.lowercased()
return videoExts.contains(ext) ? "play.rectangle" : "photo" let opts = PHImageRequestOptions()
case .local(let a): opts.deliveryMode = .fastFormat
return a.mediaType == .video ? "play.rectangle" : "photo" 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 load() async { private func loadNASImage(path: String) async -> UIImage? {
guard image == nil, !loading else { return } if let cached = ThumbnailCache.shared.get(for: path) { return cached }
guard let conn = connection else { return nil }
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 { do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: conn.host, port: conn.port, try await transfer.connect(to: conn.host, port: conn.port,
username: conn.username, password: conn.password) username: conn.username, password: conn.password)
let data = try await s.downloadData(at: nasItem.path) let data = try await transfer.downloadData(at: path)
s.disconnect() transfer.disconnect()
if let img = UIImage(data: data)?.thumbnailScaled(to: 300) { if let img = await ThumbnailCache.decodeOffThread(data: data, size: 300) {
ThumbnailCache.shared.set(img, for: nasItem.path) ThumbnailCache.shared.set(img, for: path)
image = img return img
} }
} catch {} } catch {}
loading = false return nil
}
}
case .local(let asset): // MARK: Shimmer
loading = true
let opts = PHImageRequestOptions() private struct ShimmerModifier: ViewModifier {
opts.deliveryMode = .opportunistic @State private var phase: CGFloat = -1
opts.isNetworkAccessAllowed = true
opts.isSynchronous = false func body(content: Content) -> some View {
let size = CGSize(width: 300, height: 300) content.overlay(
await withCheckedContinuation { cont in GeometryReader { geo in
PHImageManager.default().requestImage( LinearGradient(
for: asset, targetSize: size, colors: [.clear, Color.white.opacity(0.25), .clear],
contentMode: .aspectFill, options: opts startPoint: .leading,
) { img, info in endPoint: .trailing
if let img { image = img } )
let degraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false .frame(width: geo.size.width * 2)
if !degraded { cont.resume() } .offset(x: phase * geo.size.width * 2)
} .animation(
} .linear(duration: 1.4).repeatForever(autoreverses: false),
loading = false value: phase
)
}
.clipped()
)
.onAppear { phase = 1 }
} }
} }
extension View {
func shimmer() -> some View { modifier(ShimmerModifier()) }
} }

View File

@@ -0,0 +1,227 @@
import Foundation
import Photos
import UIKit
import Combine
private let imageExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"]
private let videoExts: Set<String> = ["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<Void, Never>?
private var cancellables = Set<AnyCancellable>()
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<String>()
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 {}
}
}

View File

@@ -21,6 +21,7 @@
19CB72B2DCA08EC8C8EB3103 /* UGreenCompatibilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B4A0DF226AB4C5EE9E0D16 /* UGreenCompatibilityBadge.swift */; }; 19CB72B2DCA08EC8C8EB3103 /* UGreenCompatibilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B4A0DF226AB4C5EE9E0D16 /* UGreenCompatibilityBadge.swift */; };
1AEF3190A6F5D3AB43CC521D /* BackupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A5F6ABBD052F931949B2FA /* BackupEngineTests.swift */; }; 1AEF3190A6F5D3AB43CC521D /* BackupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A5F6ABBD052F931949B2FA /* BackupEngineTests.swift */; };
20F9E76FDA60BA806980E44A /* ToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674029116718AB4525AD9450 /* ToggleRow.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 */; }; 24B0F2FC039AF5898BF3ADAE /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57448AD7D80B10985B8A222B /* Date+.swift */; };
280D31F6A61529FB13A06EA4 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = BD0A57EC048DFE706E440152 /* SMBClient */; }; 280D31F6A61529FB13A06EA4 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = BD0A57EC048DFE706E440152 /* SMBClient */; };
28C259C9EB7F49F4C2043375 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4F11D9683F64A06B855649 /* SMBService.swift */; }; 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 */; }; 48A1C445346B6E84BB4955D3 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E987653F4F7129568656EFDE /* BackupResult.swift */; };
526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA5C090CF766ED8E3AF77D1 /* KeychainStore.swift */; }; 526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA5C090CF766ED8E3AF77D1 /* KeychainStore.swift */; };
52D01C4B07CBCEB957E6A48D /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF29317285580DD2246F54F /* ThumbnailCache.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 */; }; 5861495375343105B577CFEF /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371184164F77DD3B847A75C7 /* SettingsView.swift */; };
600B0282353CDE47AA5B9069 /* NASProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8635AB32FC433B6ECA98B2D /* NASProtocolTests.swift */; }; 600B0282353CDE47AA5B9069 /* NASProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8635AB32FC433B6ECA98B2D /* NASProtocolTests.swift */; };
6328A1448CA2F792C509AFC6 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A63232F3A6456E16E36215 /* BackupView.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 */; }; D18F0F1DCFEB945F2AD6DC20 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C29AB7B029DBC37E189C81D6 /* ConnectView.swift */; };
D2EB2844309ECB331955F2A9 /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF029B56DE72F2D5D7977D95 /* SFTPService.swift */; }; D2EB2844309ECB331955F2A9 /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF029B56DE72F2D5D7977D95 /* SFTPService.swift */; };
D7C3444EF261573E590725E9 /* MockNASService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B1138B91494A6B3BF4515C0 /* MockNASService.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 */; }; E6E8A98506D79BB36771FA16 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB509370FEEE38EA8732FDE /* GalleryView.swift */; };
EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */; }; EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */; };
EE8AE9228AC7BA87E0985BDF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */; }; 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 = "<group>"; }; 0B68D1C6752070B3B79B20F9 /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
101678CDEDFF343DCC880C2F /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; }; 101678CDEDFF343DCC880C2F /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
1088EF7C38547B982CA1F220 /* LANMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitorTests.swift; sourceTree = "<group>"; }; 1088EF7C38547B982CA1F220 /* LANMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitorTests.swift; sourceTree = "<group>"; };
18ADB8B4D428026420CCA46B /* GalleryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetailView.swift; sourceTree = "<group>"; };
1AB509370FEEE38EA8732FDE /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = "<group>"; }; 1AB509370FEEE38EA8732FDE /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = "<group>"; };
1AF29317285580DD2246F54F /* ThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = "<group>"; }; 1AF29317285580DD2246F54F /* ThumbnailCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = "<group>"; };
22BF6D1FB493F75096805690 /* FolderSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSetupView.swift; sourceTree = "<group>"; }; 22BF6D1FB493F75096805690 /* FolderSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSetupView.swift; sourceTree = "<group>"; };
@@ -95,6 +99,7 @@
3CD95417C6B22E6FB7BECC4D /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; }; 3CD95417C6B22E6FB7BECC4D /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; };
3E4F11D9683F64A06B855649 /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; }; 3E4F11D9683F64A06B855649 /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; };
3F26ACA8ED4D03984F5A9C7D /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; }; 3F26ACA8ED4D03984F5A9C7D /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
409404E5F90E8FECE829E43D /* GalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryViewModel.swift; sourceTree = "<group>"; };
4512EE2A2DE276DB46690E06 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; }; 4512EE2A2DE276DB46690E06 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
57448AD7D80B10985B8A222B /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = "<group>"; }; 57448AD7D80B10985B8A222B /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = "<group>"; };
5AA5C090CF766ED8E3AF77D1 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = "<group>"; }; 5AA5C090CF766ED8E3AF77D1 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = "<group>"; };
@@ -118,6 +123,7 @@
C29AB7B029DBC37E189C81D6 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; }; C29AB7B029DBC37E189C81D6 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
C2FABB55A4E874EE203CE585 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = "<group>"; }; C2FABB55A4E874EE203CE585 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = "<group>"; };
C5FFBBC0D0A57B7476BAFBCF /* PhotoPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPermissionView.swift; sourceTree = "<group>"; }; C5FFBBC0D0A57B7476BAFBCF /* PhotoPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPermissionView.swift; sourceTree = "<group>"; };
CEE62B5210F46415531D531D /* GalleryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryItem.swift; sourceTree = "<group>"; };
D008AD85FA9B690DAFECCC34 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; }; D008AD85FA9B690DAFECCC34 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; };
D8635AB32FC433B6ECA98B2D /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = "<group>"; }; D8635AB32FC433B6ECA98B2D /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = "<group>"; };
DA38966CDE15FCE36BEA5DC5 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; }; DA38966CDE15FCE36BEA5DC5 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; };
@@ -240,6 +246,7 @@
966927456571CE45600BEF75 /* BackupManifest.swift */, 966927456571CE45600BEF75 /* BackupManifest.swift */,
E987653F4F7129568656EFDE /* BackupResult.swift */, E987653F4F7129568656EFDE /* BackupResult.swift */,
2EDEAE39E0AFC06D0C169589 /* BackupStatusSnapshot.swift */, 2EDEAE39E0AFC06D0C169589 /* BackupStatusSnapshot.swift */,
CEE62B5210F46415531D531D /* GalleryItem.swift */,
DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */, DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */,
); );
path = Models; path = Models;
@@ -323,7 +330,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6004FE7B107CC60894EDC434 /* BrowseView.swift */, 6004FE7B107CC60894EDC434 /* BrowseView.swift */,
18ADB8B4D428026420CCA46B /* GalleryDetailView.swift */,
1AB509370FEEE38EA8732FDE /* GalleryView.swift */, 1AB509370FEEE38EA8732FDE /* GalleryView.swift */,
409404E5F90E8FECE829E43D /* GalleryViewModel.swift */,
); );
path = Browse; path = Browse;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -513,7 +522,10 @@
24B0F2FC039AF5898BF3ADAE /* Date+.swift in Sources */, 24B0F2FC039AF5898BF3ADAE /* Date+.swift in Sources */,
3165724C02C31367688DC9A1 /* FolderRow.swift in Sources */, 3165724C02C31367688DC9A1 /* FolderRow.swift in Sources */,
FFC6FD2A10B12C5536CC8BFF /* FolderSetupView.swift in Sources */, FFC6FD2A10B12C5536CC8BFF /* FolderSetupView.swift in Sources */,
218313D06E536CC541620B1A /* GalleryDetailView.swift in Sources */,
D8247C4721601698EB006C86 /* GalleryItem.swift in Sources */,
E6E8A98506D79BB36771FA16 /* GalleryView.swift in Sources */, E6E8A98506D79BB36771FA16 /* GalleryView.swift in Sources */,
54744C00729FAAB9715B06B5 /* GalleryViewModel.swift in Sources */,
0FCDDB2EAE2937413C4290BC /* HistoryView.swift in Sources */, 0FCDDB2EAE2937413C4290BC /* HistoryView.swift in Sources */,
526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */, 526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */,
EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */, EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */,

View File

@@ -13,6 +13,7 @@ final class BackupStatusService: NSObject, ObservableObject {
@Published private(set) var snapshot: BackupStatusSnapshot = .empty @Published private(set) var snapshot: BackupStatusSnapshot = .empty
@Published private(set) var isRefreshing: Bool = false @Published private(set) var isRefreshing: Bool = false
@Published private(set) var refreshError: String? @Published private(set) var refreshError: String?
@Published private(set) var lastManifest: BackupManifest?
private let photoService = PhotoLibraryService() private let photoService = PhotoLibraryService()
private let cacheKey = "backupStatusSnapshot_v2" private let cacheKey = "backupStatusSnapshot_v2"
@@ -204,12 +205,15 @@ final class BackupStatusService: NSObject, ObservableObject {
do { do {
let data = try await transfer.downloadData(at: path) let data = try await transfer.downloadData(at: path)
// Decode and build index off main actor JSONDecoder is not cheap for large manifests. // 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) guard let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data)
else { return nil } else { return nil }
return ManifestIndex(manifest: manifest) return (ManifestIndex(manifest: manifest), manifest)
}.value }.value
if let index { return index } if let (index, manifest) = decoded {
lastManifest = manifest
return index
}
} catch { } catch {
// File not found fall through to directory listing bootstrap // File not found fall through to directory listing bootstrap
} }

View File

@@ -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 { extension UIImage {
func thumbnailScaled(to size: CGFloat) -> UIImage { func thumbnailScaled(to size: CGFloat) -> UIImage {
let scale = min(size / self.size.width, size / self.size.height) let scale = min(size / self.size.width, size / self.size.height)