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