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
|
||||
}
|
||||
|
||||
struct BackupManifest: Codable {
|
||||
struct BackupManifest: Codable, Sendable {
|
||||
static let remoteFilename = ".kisani.json"
|
||||
|
||||
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 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 {
|
||||
@EnvironmentObject var store: ConnectionStore
|
||||
@EnvironmentObject var lanMonitor: LANMonitor
|
||||
@EnvironmentObject var statusService: BackupStatusService
|
||||
@StateObject private var viewModel = GalleryViewModel()
|
||||
|
||||
@State private var source: GallerySource = .nas
|
||||
|
||||
// NAS state
|
||||
@State private var nasItems: [NASItem] = []
|
||||
@State private var nasLoading = false
|
||||
@State private var nasError: String? = nil
|
||||
|
||||
// Local state
|
||||
@State private var localAssets: [PHAsset] = []
|
||||
@State private var syncedNames: Set<String> = []
|
||||
@State private var localAuthDenied = false
|
||||
|
||||
@State private var appeared = false
|
||||
|
||||
private var connection: NASConnection? { store.savedConnection }
|
||||
private let deviceName = UIDevice.current.name
|
||||
@State private var selectedItem: GalleryItem?
|
||||
@State private var searchVisible = false
|
||||
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: 2),
|
||||
@@ -35,395 +17,388 @@ struct GalleryView: View {
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ZStack(alignment: .top) {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
switch source {
|
||||
case .nas: nasContent
|
||||
case .local: localContent
|
||||
VStack(spacing: 0) {
|
||||
tabBar
|
||||
if searchVisible { searchBar }
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationTitle("Gallery")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { sourceChip }
|
||||
.toolbar { trailingItems }
|
||||
.fullScreenCover(item: $selectedItem) { item in
|
||||
GalleryDetailView(
|
||||
item: item,
|
||||
allItems: viewModel.allItems,
|
||||
connection: store.savedConnection
|
||||
)
|
||||
}
|
||||
.task {
|
||||
await loadNAS()
|
||||
loadLocal()
|
||||
await viewModel.load(connection: store.savedConnection, statusService: statusService)
|
||||
}
|
||||
.refreshable {
|
||||
await loadNAS()
|
||||
loadLocal()
|
||||
await viewModel.load(connection: store.savedConnection, statusService: statusService)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Source chip / dropdown
|
||||
// MARK: — Toolbar
|
||||
|
||||
private var sourceChip: some View {
|
||||
Menu {
|
||||
private var trailingItems: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas }
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
searchVisible.toggle()
|
||||
if !searchVisible { viewModel.searchText = "" }
|
||||
}
|
||||
} label: {
|
||||
Label(connection?.host ?? "NAS", systemImage: "externaldrive.fill")
|
||||
}
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .local }
|
||||
} label: {
|
||||
Label(deviceName, systemImage: "iphone")
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(dotColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dotColor == AppTheme.positive)
|
||||
Text(source == .nas ? (connection?.host ?? "NAS") : deviceName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Image(systemName: searchVisible ? "xmark.circle.fill" : "magnifyingglass")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
switch source {
|
||||
case .nas:
|
||||
switch lanMonitor.nasReachable {
|
||||
case true: return AppTheme.positive
|
||||
case false: return AppTheme.destructive
|
||||
case nil: return AppTheme.inkQuaternary
|
||||
}
|
||||
case .local:
|
||||
return AppTheme.interactive
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — NAS content
|
||||
|
||||
private var nasContent: some View {
|
||||
Group {
|
||||
if connection == nil {
|
||||
placeholder(icon: "externaldrive.badge.xmark", text: "No NAS connected")
|
||||
} else if nasLoading && nasItems.isEmpty {
|
||||
loadingView("Loading gallery…")
|
||||
} else if let err = nasError {
|
||||
errorView(err) { Task { await loadNAS() } }
|
||||
} else if nasItems.isEmpty {
|
||||
placeholder(icon: "photo.on.rectangle", text: "No photos backed up yet")
|
||||
} else {
|
||||
gallery(items: nasItems.map { .nas($0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Local content
|
||||
// MARK: — Tab bar
|
||||
|
||||
private var localContent: some View {
|
||||
Group {
|
||||
if localAuthDenied {
|
||||
placeholder(icon: "lock.slash", text: "Photo library access denied")
|
||||
} else if localAssets.isEmpty {
|
||||
loadingView("Loading photos…")
|
||||
} else {
|
||||
gallery(items: localAssets.map { .local($0) })
|
||||
private var tabBar: some View {
|
||||
Picker("", selection: $viewModel.tab) {
|
||||
ForEach(GalleryViewModel.Tab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: — Shared grid
|
||||
// MARK: — Search
|
||||
|
||||
private func gallery(items: [GalleryItem]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: 2) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { idx, item in
|
||||
GalleryCell(
|
||||
item: item,
|
||||
connection: connection,
|
||||
isSynced: syncStatus(for: item)
|
||||
)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(
|
||||
.spring(response: 0.35, dampingFraction: 0.82)
|
||||
.delay(Double(min(idx, 18)) * 0.02),
|
||||
value: appeared
|
||||
)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
gridHeader(count: items.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
|
||||
private func gridHeader(count: Int) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(count) files")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Group {
|
||||
switch source {
|
||||
case .nas:
|
||||
if let conn = connection {
|
||||
Text((conn.remotePath as NSString).lastPathComponent)
|
||||
}
|
||||
case .local:
|
||||
Text(deviceName)
|
||||
}
|
||||
}
|
||||
.font(AppTheme.micro())
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
TextField("Search filenames", text: $viewModel.searchText)
|
||||
.font(AppTheme.body(14))
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
if !viewModel.searchText.isEmpty {
|
||||
Button { viewModel.searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 6)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// MARK: — Content states
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if viewModel.isLoading && viewModel.sections.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().tint(AppTheme.inkTertiary)
|
||||
Text("Loading gallery…")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Spacer()
|
||||
if nasLoading {
|
||||
ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary)
|
||||
} else if viewModel.sections.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: emptyIcon)
|
||||
.font(.system(size: 38, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(emptyMessage)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 10)
|
||||
.background(AppTheme.background)
|
||||
}
|
||||
|
||||
// MARK: — Sync status
|
||||
|
||||
private func syncStatus(for item: GalleryItem) -> SyncStatus {
|
||||
guard source == .local else { return .none }
|
||||
switch item {
|
||||
case .local(let asset):
|
||||
let name = PHAssetResource.assetResources(for: asset)
|
||||
.first?.originalFilename.lowercased() ?? ""
|
||||
return syncedNames.contains(name) ? .synced : .pending
|
||||
case .nas:
|
||||
return .none
|
||||
Spacer()
|
||||
} else {
|
||||
photoGrid
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Data
|
||||
|
||||
private func loadNAS() async {
|
||||
guard let conn = connection else { return }
|
||||
nasLoading = true
|
||||
nasError = nil
|
||||
do {
|
||||
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await s.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
nasItems = try await s.listDirectory(at: conn.remotePath)
|
||||
.filter { !$0.isDirectory }
|
||||
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
|
||||
s.disconnect()
|
||||
syncedNames = Set(nasItems.map { $0.name.lowercased() })
|
||||
} catch let e as BackupError {
|
||||
nasError = e.errorDescription
|
||||
} catch {
|
||||
nasError = error.localizedDescription
|
||||
private var emptyIcon: String {
|
||||
switch viewModel.tab {
|
||||
case .all, .phone: "photo.on.rectangle.angled"
|
||||
case .nas: "externaldrive.badge.questionmark"
|
||||
}
|
||||
nasLoading = false
|
||||
}
|
||||
|
||||
private func loadLocal() {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
guard status == .authorized || status == .limited else {
|
||||
if status == .denied || status == .restricted { localAuthDenied = true }
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { granted in
|
||||
Task { @MainActor in
|
||||
if granted == .authorized || granted == .limited {
|
||||
loadLocalAssets()
|
||||
} else {
|
||||
localAuthDenied = true
|
||||
private var emptyMessage: String {
|
||||
let q = viewModel.searchText.trimmingCharacters(in: .whitespaces)
|
||||
if !q.isEmpty { return "No results for \"\(q)\"" }
|
||||
switch viewModel.tab {
|
||||
case .all: return "No photos yet"
|
||||
case .phone: return "No photos on this device"
|
||||
case .nas: return "No backed-up files found"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Grid
|
||||
|
||||
private var photoGrid: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(viewModel.sections) { section in
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: 2) {
|
||||
ForEach(section.items) { item in
|
||||
GalleryCell(item: item, connection: store.savedConnection)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectedItem = item }
|
||||
.contextMenu { itemContextMenu(item) }
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
sectionHeader(for: section)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
loadLocalAssets()
|
||||
}
|
||||
|
||||
private func loadLocalAssets() {
|
||||
let opts = PHFetchOptions()
|
||||
opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
let result = PHAsset.fetchAssets(with: opts)
|
||||
var assets: [PHAsset] = []
|
||||
result.enumerateObjects { asset, _, _ in assets.append(asset) }
|
||||
localAssets = assets
|
||||
}
|
||||
|
||||
// MARK: — Utility views
|
||||
|
||||
private func loadingView(_ text: String) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView().tint(AppTheme.inkTertiary)
|
||||
Text(text).font(AppTheme.caption()).foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private func placeholder(icon: String, text: String) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 32, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(text)
|
||||
.font(AppTheme.caption())
|
||||
private func sectionHeader(for section: GallerySection) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(section.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Spacer()
|
||||
Text("\(section.items.count)")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 7)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private func errorView(_ message: String, retry: @escaping () -> Void) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
placeholder(icon: "wifi.exclamationmark", text: message)
|
||||
Button(action: retry) {
|
||||
Text("Retry")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
// MARK: — Context menu
|
||||
|
||||
@ViewBuilder
|
||||
private func itemContextMenu(_ item: GalleryItem) -> some View {
|
||||
Button {
|
||||
selectedItem = item
|
||||
} label: {
|
||||
Label("View", systemImage: "eye")
|
||||
}
|
||||
|
||||
if item.source.localIdentifier != nil {
|
||||
Button { sharePhoneAsset(item) } label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
|
||||
if case .nas = item.source {
|
||||
Button {
|
||||
Task { await viewModel.storeLocally(item: item, connection: store.savedConnection) }
|
||||
} label: {
|
||||
Label("Save to Photos", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if item.source.localIdentifier != nil {
|
||||
Button(role: .destructive) { deleteFromDevice(item) } label: {
|
||||
Label("Delete from Device", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sharePhoneAsset(_ item: GalleryItem) {
|
||||
guard let id = item.source.localIdentifier else { return }
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil)
|
||||
guard let asset = result.firstObject else { return }
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, _, _, _ in
|
||||
guard let data, let img = UIImage(data: data) else { return }
|
||||
let vc = UIActivityViewController(activityItems: [img], applicationActivities: nil)
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else { return }
|
||||
DispatchQueue.main.async { root.present(vc, animated: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteFromDevice(_ item: GalleryItem) {
|
||||
guard let id = item.source.localIdentifier else { return }
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil)
|
||||
guard result.count > 0 else { return }
|
||||
PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetChangeRequest.deleteAssets(result)
|
||||
} completionHandler: { success, _ in
|
||||
if success {
|
||||
Task { @MainActor in
|
||||
await viewModel.load(connection: store.savedConnection, statusService: statusService)
|
||||
}
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Gallery item model
|
||||
|
||||
enum GalleryItem {
|
||||
case nas(NASItem)
|
||||
case local(PHAsset)
|
||||
}
|
||||
|
||||
enum SyncStatus { case none, synced, pending }
|
||||
|
||||
// MARK: — Cell
|
||||
|
||||
struct GalleryCell: View {
|
||||
let item: GalleryItem
|
||||
let connection: NASConnection?
|
||||
let isSynced: SyncStatus
|
||||
|
||||
@State private var image: UIImage? = nil
|
||||
@State private var loading = false
|
||||
@State private var image: UIImage?
|
||||
@State private var taskID = UUID()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if let img = image {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.transition(.opacity.animation(.easeIn(duration: 0.18)))
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.overlay {
|
||||
if loading {
|
||||
ProgressView().scaleEffect(0.55).tint(AppTheme.inkQuaternary)
|
||||
} else {
|
||||
Image(systemName: cellIcon)
|
||||
.font(.system(size: 18, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video badge
|
||||
if isVideo && image != nil {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.background(.black.opacity(0.45))
|
||||
.clipShape(Circle())
|
||||
.padding(5)
|
||||
}
|
||||
|
||||
// Sync status badge (local mode only)
|
||||
if isSynced != .none {
|
||||
Circle()
|
||||
.fill(isSynced == .synced ? AppTheme.positive : Color(red: 1.0, green: 0.58, blue: 0.0))
|
||||
.frame(width: 9, height: 9)
|
||||
.overlay(Circle().stroke(Color.white, lineWidth: 1.5))
|
||||
.padding(5)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSynced == .synced)
|
||||
ZStack {
|
||||
cellBackground
|
||||
if item.mediaType == .video, image != nil {
|
||||
videoOverlay.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||
}
|
||||
syncDot.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipped()
|
||||
.task { await load() }
|
||||
.id(taskID)
|
||||
.task(id: taskID) { await loadThumbnail() }
|
||||
}
|
||||
|
||||
private var isVideo: Bool {
|
||||
switch item {
|
||||
case .nas(let n): return videoExts.contains((n.name as NSString).pathExtension.lowercased())
|
||||
case .local(let a): return a.mediaType == .video
|
||||
@ViewBuilder
|
||||
private var cellBackground: some View {
|
||||
if let img = image {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.transition(.opacity.animation(.easeIn(duration: 0.18)))
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.shimmer()
|
||||
}
|
||||
}
|
||||
|
||||
private var cellIcon: String {
|
||||
switch item {
|
||||
case .nas(let n):
|
||||
let ext = (n.name as NSString).pathExtension.lowercased()
|
||||
return videoExts.contains(ext) ? "play.rectangle" : "photo"
|
||||
case .local(let a):
|
||||
return a.mediaType == .video ? "play.rectangle" : "photo"
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard image == nil, !loading else { return }
|
||||
|
||||
switch item {
|
||||
case .nas(let nasItem):
|
||||
guard let conn = connection else { return }
|
||||
let ext = (nasItem.name as NSString).pathExtension.lowercased()
|
||||
guard imageExts.contains(ext) || videoExts.contains(ext) else { return }
|
||||
if let cached = ThumbnailCache.shared.get(for: nasItem.path) { image = cached; return }
|
||||
loading = true
|
||||
do {
|
||||
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await s.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
let data = try await s.downloadData(at: nasItem.path)
|
||||
s.disconnect()
|
||||
if let img = UIImage(data: data)?.thumbnailScaled(to: 300) {
|
||||
ThumbnailCache.shared.set(img, for: nasItem.path)
|
||||
image = img
|
||||
}
|
||||
} catch {}
|
||||
loading = false
|
||||
|
||||
case .local(let asset):
|
||||
loading = true
|
||||
let opts = PHImageRequestOptions()
|
||||
opts.deliveryMode = .opportunistic
|
||||
opts.isNetworkAccessAllowed = true
|
||||
opts.isSynchronous = false
|
||||
let size = CGSize(width: 300, height: 300)
|
||||
await withCheckedContinuation { cont in
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset, targetSize: size,
|
||||
contentMode: .aspectFill, options: opts
|
||||
) { img, info in
|
||||
if let img { image = img }
|
||||
let degraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false
|
||||
if !degraded { cont.resume() }
|
||||
}
|
||||
private var videoOverlay: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
if let dur = item.duration {
|
||||
Text(formatDuration(dur))
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 3)
|
||||
.background(.black.opacity(0.55))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.padding(5)
|
||||
}
|
||||
|
||||
private var syncDot: some View {
|
||||
let color: Color = switch item.syncState {
|
||||
case .synced: AppTheme.positive
|
||||
case .phoneOnly: Color(red: 1.0, green: 0.58, blue: 0.0)
|
||||
case .nasOnly: AppTheme.interactive
|
||||
}
|
||||
return Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(Circle().stroke(Color.white, lineWidth: 1.5))
|
||||
.padding(5)
|
||||
}
|
||||
|
||||
private func formatDuration(_ s: TimeInterval) -> String {
|
||||
let t = Int(s)
|
||||
return t >= 3600
|
||||
? String(format: "%d:%02d:%02d", t / 3600, (t % 3600) / 60, t % 60)
|
||||
: String(format: "%d:%02d", t / 60, t % 60)
|
||||
}
|
||||
|
||||
// MARK: — Thumbnail loading
|
||||
|
||||
private func loadThumbnail() async {
|
||||
guard image == nil else { return }
|
||||
switch item.source {
|
||||
case .phone(let id), .synced(let id, _):
|
||||
image = await loadPhoneImage(localID: id)
|
||||
case .nas(let path):
|
||||
image = await loadNASImage(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPhoneImage(localID: String) async -> UIImage? {
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [localID], options: nil)
|
||||
guard let asset = result.firstObject else { return nil }
|
||||
|
||||
let opts = PHImageRequestOptions()
|
||||
opts.deliveryMode = .fastFormat
|
||||
opts.isNetworkAccessAllowed = false
|
||||
opts.isSynchronous = false
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: CGSize(width: 300, height: 300),
|
||||
contentMode: .aspectFill,
|
||||
options: opts
|
||||
) { img, _ in cont.resume(returning: img) }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadNASImage(path: String) async -> UIImage? {
|
||||
if let cached = ThumbnailCache.shared.get(for: path) { return cached }
|
||||
guard let conn = connection else { return nil }
|
||||
do {
|
||||
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await transfer.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
let data = try await transfer.downloadData(at: path)
|
||||
transfer.disconnect()
|
||||
if let img = await ThumbnailCache.decodeOffThread(data: data, size: 300) {
|
||||
ThumbnailCache.shared.set(img, for: path)
|
||||
return img
|
||||
}
|
||||
} catch {}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Shimmer
|
||||
|
||||
private struct ShimmerModifier: ViewModifier {
|
||||
@State private var phase: CGFloat = -1
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.overlay(
|
||||
GeometryReader { geo in
|
||||
LinearGradient(
|
||||
colors: [.clear, Color.white.opacity(0.25), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: geo.size.width * 2)
|
||||
.offset(x: phase * geo.size.width * 2)
|
||||
.animation(
|
||||
.linear(duration: 1.4).repeatForever(autoreverses: false),
|
||||
value: phase
|
||||
)
|
||||
}
|
||||
.clipped()
|
||||
)
|
||||
.onAppear { phase = 1 }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func shimmer() -> some View { modifier(ShimmerModifier()) }
|
||||
}
|
||||
|
||||
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 */; };
|
||||
1AEF3190A6F5D3AB43CC521D /* BackupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A5F6ABBD052F931949B2FA /* BackupEngineTests.swift */; };
|
||||
20F9E76FDA60BA806980E44A /* ToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674029116718AB4525AD9450 /* ToggleRow.swift */; };
|
||||
218313D06E536CC541620B1A /* GalleryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18ADB8B4D428026420CCA46B /* GalleryDetailView.swift */; };
|
||||
24B0F2FC039AF5898BF3ADAE /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57448AD7D80B10985B8A222B /* Date+.swift */; };
|
||||
280D31F6A61529FB13A06EA4 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = BD0A57EC048DFE706E440152 /* SMBClient */; };
|
||||
28C259C9EB7F49F4C2043375 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4F11D9683F64A06B855649 /* SMBService.swift */; };
|
||||
@@ -34,6 +35,7 @@
|
||||
48A1C445346B6E84BB4955D3 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E987653F4F7129568656EFDE /* BackupResult.swift */; };
|
||||
526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA5C090CF766ED8E3AF77D1 /* KeychainStore.swift */; };
|
||||
52D01C4B07CBCEB957E6A48D /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF29317285580DD2246F54F /* ThumbnailCache.swift */; };
|
||||
54744C00729FAAB9715B06B5 /* GalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409404E5F90E8FECE829E43D /* GalleryViewModel.swift */; };
|
||||
5861495375343105B577CFEF /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371184164F77DD3B847A75C7 /* SettingsView.swift */; };
|
||||
600B0282353CDE47AA5B9069 /* NASProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8635AB32FC433B6ECA98B2D /* NASProtocolTests.swift */; };
|
||||
6328A1448CA2F792C509AFC6 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A63232F3A6456E16E36215 /* BackupView.swift */; };
|
||||
@@ -59,6 +61,7 @@
|
||||
D18F0F1DCFEB945F2AD6DC20 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C29AB7B029DBC37E189C81D6 /* ConnectView.swift */; };
|
||||
D2EB2844309ECB331955F2A9 /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF029B56DE72F2D5D7977D95 /* SFTPService.swift */; };
|
||||
D7C3444EF261573E590725E9 /* MockNASService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B1138B91494A6B3BF4515C0 /* MockNASService.swift */; };
|
||||
D8247C4721601698EB006C86 /* GalleryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE62B5210F46415531D531D /* GalleryItem.swift */; };
|
||||
E6E8A98506D79BB36771FA16 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB509370FEEE38EA8732FDE /* GalleryView.swift */; };
|
||||
EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */; };
|
||||
EE8AE9228AC7BA87E0985BDF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */; };
|
||||
@@ -81,6 +84,7 @@
|
||||
0B68D1C6752070B3B79B20F9 /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -95,6 +99,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -118,6 +123,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -240,6 +246,7 @@
|
||||
966927456571CE45600BEF75 /* BackupManifest.swift */,
|
||||
E987653F4F7129568656EFDE /* BackupResult.swift */,
|
||||
2EDEAE39E0AFC06D0C169589 /* BackupStatusSnapshot.swift */,
|
||||
CEE62B5210F46415531D531D /* GalleryItem.swift */,
|
||||
DAF503BCE3AEDC25F4D35083 /* NASConnection.swift */,
|
||||
);
|
||||
path = Models;
|
||||
@@ -323,7 +330,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6004FE7B107CC60894EDC434 /* BrowseView.swift */,
|
||||
18ADB8B4D428026420CCA46B /* GalleryDetailView.swift */,
|
||||
1AB509370FEEE38EA8732FDE /* GalleryView.swift */,
|
||||
409404E5F90E8FECE829E43D /* GalleryViewModel.swift */,
|
||||
);
|
||||
path = Browse;
|
||||
sourceTree = "<group>";
|
||||
@@ -513,7 +522,10 @@
|
||||
24B0F2FC039AF5898BF3ADAE /* Date+.swift in Sources */,
|
||||
3165724C02C31367688DC9A1 /* FolderRow.swift in Sources */,
|
||||
FFC6FD2A10B12C5536CC8BFF /* FolderSetupView.swift in Sources */,
|
||||
218313D06E536CC541620B1A /* GalleryDetailView.swift in Sources */,
|
||||
D8247C4721601698EB006C86 /* GalleryItem.swift in Sources */,
|
||||
E6E8A98506D79BB36771FA16 /* GalleryView.swift in Sources */,
|
||||
54744C00729FAAB9715B06B5 /* GalleryViewModel.swift in Sources */,
|
||||
0FCDDB2EAE2937413C4290BC /* HistoryView.swift in Sources */,
|
||||
526A705B348CD4C8AC96576D /* KeychainStore.swift in Sources */,
|
||||
EAD70575AAC91030698676E1 /* KisaniLogoMark.swift in Sources */,
|
||||
|
||||
@@ -13,6 +13,7 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
@Published private(set) var snapshot: BackupStatusSnapshot = .empty
|
||||
@Published private(set) var isRefreshing: Bool = false
|
||||
@Published private(set) var refreshError: String?
|
||||
@Published private(set) var lastManifest: BackupManifest?
|
||||
|
||||
private let photoService = PhotoLibraryService()
|
||||
private let cacheKey = "backupStatusSnapshot_v2"
|
||||
@@ -204,12 +205,15 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
do {
|
||||
let data = try await transfer.downloadData(at: path)
|
||||
// Decode and build index off main actor — JSONDecoder is not cheap for large manifests.
|
||||
let index: ManifestIndex? = await Task.detached(priority: .utility) {
|
||||
let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) {
|
||||
guard let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data)
|
||||
else { return nil }
|
||||
return ManifestIndex(manifest: manifest)
|
||||
return (ManifestIndex(manifest: manifest), manifest)
|
||||
}.value
|
||||
if let index { return index }
|
||||
if let (index, manifest) = decoded {
|
||||
lastManifest = manifest
|
||||
return index
|
||||
}
|
||||
} catch {
|
||||
// File not found — fall through to directory listing bootstrap
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ final class ThumbnailCache {
|
||||
}
|
||||
}
|
||||
|
||||
extension ThumbnailCache {
|
||||
static func decodeOffThread(data: Data, size: CGFloat) async -> UIImage? {
|
||||
await Task.detached(priority: .userInitiated) {
|
||||
UIImage(data: data)?.thumbnailScaled(to: size)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func thumbnailScaled(to size: CGFloat) -> UIImage {
|
||||
let scale = min(size / self.size.width, size / self.size.height)
|
||||
|
||||
Reference in New Issue
Block a user