Files
Kisani/Features/Browse/GalleryView.swift

404 lines
14 KiB
Swift
Raw Permalink Normal View History

import SwiftUI
import Photos
import UIKit
struct GalleryView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var statusService: BackupStatusService
@StateObject private var viewModel = GalleryViewModel()
@State private var selectedItem: GalleryItem?
@State private var searchVisible = false
private let columns = [
GridItem(.flexible(), spacing: 2),
GridItem(.flexible(), spacing: 2),
GridItem(.flexible(), spacing: 2)
]
var body: some View {
ZStack(alignment: .top) {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
tabBar
if searchVisible { searchBar }
mainContent
}
}
.navigationTitle("Gallery")
.navigationBarTitleDisplayMode(.inline)
.toolbar { trailingItems }
.fullScreenCover(item: $selectedItem) { item in
GalleryDetailView(
item: item,
allItems: viewModel.allItems,
connection: store.savedConnection
)
}
.task {
viewModel.bind(to: statusService, connection: store.savedConnection)
await viewModel.load(connection: store.savedConnection, statusService: statusService)
}
.refreshable {
await viewModel.load(connection: store.savedConnection, statusService: statusService)
}
}
// MARK: Toolbar
private var trailingItems: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
searchVisible.toggle()
if !searchVisible { viewModel.searchText = "" }
}
} label: {
Image(systemName: searchVisible ? "xmark.circle.fill" : "magnifyingglass")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
}
}
// MARK: Tab bar
private var tabBar: some View {
Picker("", selection: $viewModel.tab) {
ForEach(GalleryViewModel.Tab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 8)
}
// MARK: Search
private var searchBar: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13))
.foregroundStyle(AppTheme.inkTertiary)
TextField("Search filenames", text: $viewModel.searchText)
.font(AppTheme.body(14))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if !viewModel.searchText.isEmpty {
Button { viewModel.searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 13))
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 6)
.transition(.move(edge: .top).combined(with: .opacity))
}
// MARK: Content states
@ViewBuilder
private var mainContent: some View {
if viewModel.isLoading && viewModel.sections.isEmpty {
Spacer()
VStack(spacing: 12) {
ProgressView().tint(AppTheme.inkTertiary)
Text("Loading gallery…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
} else if viewModel.sections.isEmpty {
Spacer()
VStack(spacing: 12) {
Image(systemName: emptyIcon)
.font(.system(size: 38, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
Text(emptyMessage)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
Spacer()
} else {
photoGrid
}
}
private var emptyIcon: String {
switch viewModel.tab {
case .all, .phone: "photo.on.rectangle.angled"
case .nas: "externaldrive.badge.questionmark"
}
}
private var emptyMessage: String {
let q = viewModel.searchText.trimmingCharacters(in: .whitespaces)
if !q.isEmpty { return "No results for \"\(q)\"" }
switch viewModel.tab {
case .all: return "No photos yet"
case .phone: return "No photos on this device"
case .nas: return "No backed-up files found"
}
}
// MARK: Grid
private var photoGrid: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(viewModel.sections) { section in
Section {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(section.items) { item in
GalleryCell(item: item, connection: store.savedConnection)
.contentShape(Rectangle())
.onTapGesture { selectedItem = item }
.contextMenu { itemContextMenu(item) }
}
}
} header: {
sectionHeader(for: section)
}
}
}
}
}
private func sectionHeader(for section: GallerySection) -> some View {
HStack(spacing: 6) {
Text(section.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Spacer()
Text("\(section.items.count)")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 7)
.background(.ultraThinMaterial)
}
// MARK: Context menu
@ViewBuilder
private func itemContextMenu(_ item: GalleryItem) -> some View {
Button {
selectedItem = item
} label: {
Label("View", systemImage: "eye")
}
if item.source.localIdentifier != nil {
Button { sharePhoneAsset(item) } label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
if case .nas = item.source {
Button {
Task { await viewModel.storeLocally(item: item, connection: store.savedConnection) }
} label: {
Label("Save to Photos", systemImage: "square.and.arrow.down")
}
}
Divider()
if item.source.localIdentifier != nil {
Button(role: .destructive) { deleteFromDevice(item) } label: {
Label("Delete from Device", systemImage: "trash")
}
}
}
private func sharePhoneAsset(_ item: GalleryItem) {
guard let id = item.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 deleteFromDevice(_ item: GalleryItem) {
guard let id = item.source.localIdentifier else { return }
let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil)
guard result.count > 0 else { return }
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.deleteAssets(result)
} completionHandler: { success, _ in
if success {
Task { @MainActor in
await viewModel.load(connection: store.savedConnection, statusService: statusService)
}
}
}
}
}
// MARK: Cell
struct GalleryCell: View {
let item: GalleryItem
let connection: NASConnection?
@State private var image: UIImage?
@State private var taskID = UUID()
var body: some View {
// Color.clear anchors the 1:1 square; .overlay constrains all children to that
// exact frame so scaledToFill never inflates the cell height in LazyVGrid.
Color.clear
.aspectRatio(1, contentMode: .fit)
.overlay {
if let img = image {
Image(uiImage: img)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeIn(duration: 0.18)))
} else {
Rectangle()
.fill(AppTheme.surfaceSunken)
.shimmer()
}
}
.overlay(alignment: .bottomLeading) {
if item.mediaType == .video, image != nil { videoOverlay }
}
.overlay(alignment: .bottomTrailing) { syncDot }
.clipped()
.id(taskID)
.task(id: taskID) { await loadThumbnail() }
}
private var videoOverlay: some View {
HStack(spacing: 3) {
Image(systemName: "play.fill")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
if let dur = item.duration {
Text(formatDuration(dur))
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
}
}
.padding(.horizontal, 5)
.padding(.vertical, 3)
.background(.black.opacity(0.55))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
.padding(5)
}
private var syncDot: some View {
let color: Color = switch item.syncState {
case .synced: AppTheme.positive
case .phoneOnly: Color(red: 1.0, green: 0.58, blue: 0.0)
case .nasOnly: AppTheme.interactive
}
return Circle()
.fill(color)
.frame(width: 8, height: 8)
.overlay(Circle().stroke(Color.white, lineWidth: 1.5))
.padding(5)
}
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: Thumbnail loading
private func loadThumbnail() async {
guard image == nil else { return }
switch item.source {
case .phone(let id), .synced(let id, _):
image = await loadPhoneImage(localID: id)
case .nas(let path):
image = await loadNASImage(path: path)
}
}
private func loadPhoneImage(localID: String) async -> UIImage? {
let result = PHAsset.fetchAssets(withLocalIdentifiers: [localID], options: nil)
guard let asset = result.firstObject else { return nil }
let opts = PHImageRequestOptions()
opts.deliveryMode = .fastFormat
opts.isNetworkAccessAllowed = false
opts.isSynchronous = false
return await withCheckedContinuation { cont in
PHImageManager.default().requestImage(
for: asset,
targetSize: CGSize(width: 300, height: 300),
contentMode: .aspectFill,
options: opts
) { img, _ in cont.resume(returning: img) }
}
}
private func loadNASImage(path: String) async -> UIImage? {
if let cached = ThumbnailCache.shared.get(for: path) { return cached }
guard let conn = connection else { return nil }
do {
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
try await transfer.connect(to: conn.host, port: conn.port,
username: conn.username, password: conn.password)
let data = try await transfer.downloadData(at: path)
transfer.disconnect()
if let img = await ThumbnailCache.decodeOffThread(data: data, size: 300) {
ThumbnailCache.shared.set(img, for: path)
return img
}
} catch {}
return nil
}
}
// MARK: Shimmer
private struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = -1
func body(content: Content) -> some View {
content.overlay(
GeometryReader { geo in
LinearGradient(
colors: [.clear, Color.white.opacity(0.25), .clear],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geo.size.width * 2)
.offset(x: phase * geo.size.width * 2)
.animation(
.linear(duration: 1.4).repeatForever(autoreverses: false),
value: phase
)
}
.clipped()
)
.onAppear { phase = 1 }
}
}
extension View {
func shimmer() -> some View { modifier(ShimmerModifier()) }
}