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:
@@ -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
|
||||||
|
|||||||
47
Core/Models/GalleryItem.swift
Normal file
47
Core/Models/GalleryItem.swift
Normal 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]
|
||||||
|
}
|
||||||
319
Features/Browse/GalleryDetailView.swift
Normal file
319
Features/Browse/GalleryDetailView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()) }
|
||||||
}
|
}
|
||||||
|
|||||||
227
Features/Browse/GalleryViewModel.swift
Normal file
227
Features/Browse/GalleryViewModel.swift
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user