Replace ZStack-based cell with Color.clear.aspectRatio(1,.fit) + .overlay. The overlay constrains all children (image, video badge, sync dot) to the exact square frame, so scaledToFill cannot inflate the cell height in LazyVGrid. Previously, the ZStack let the filled image push beyond the aspectRatio constraint, causing inconsistent row heights. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
404 lines
14 KiB
Swift
404 lines
14 KiB
Swift
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()) }
|
|
}
|