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:
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 {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user