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

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