320 lines
11 KiB
Swift
320 lines
11 KiB
Swift
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|