Files
Kisani/Features/Browse/GalleryDetailView.swift

320 lines
11 KiB
Swift
Raw Permalink Normal View History

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