feat: redesign Gallery as photo timeline with sync state, deduplication, and detail view

- GalleryItem: unified model with phone/nas/synced source, sync state enum
- GalleryViewModel: off-actor PHFetchResult + manifest merge, Phone/NAS/All tab filtering, date section grouping, Combine-driven reactivity
- GalleryView: sticky date section headers, 3-col grid, shimmer placeholders, sync dots (green/orange/blue), video duration badge, search bar, context menu (Share, Save to Photos, Delete from Device)
- GalleryDetailView: full-screen swipeable photo viewer (TabView page mode), pinch-to-zoom, double-tap zoom toggle, bottom metadata sheet with file info and actions
- BackupStatusService: expose lastManifest for gallery deduplication without extra NAS connection
- ThumbnailCache: add decodeOffThread static helper for off-actor JPEG decode
- BackupManifest: Sendable conformance for safe Task.detached capture
- ThumbnailCache: synced items load from PHImageManager (instant) instead of NAS

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

View File

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