Files
Kisani/Features/Browse/GalleryView.swift
Robin Kutesa 0bcb8f849c Force uniform 1:1 square crop on all gallery cells
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>
2026-05-19 09:53:42 +03:00

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