GalleryViewModel.load() now has a three-tier resolution for manifest entries:
1. statusService.lastManifest — in-memory, freshest
2. NASManifestCache disk cache — survives restarts
3. Direct NAS download — fallback when both caches are cold (first launch after install,
or before BackupStatusService reconcile has completed)
The direct download also populates NASManifestCache so subsequent opens are instant.
Also adds GalleryViewModel.bind(to:connection:) which subscribes to
statusService.$lastManifest and automatically reloads gallery items when
BackupStatusService finishes reconciling — gallery updates without manual pull-to-refresh.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
406 lines
14 KiB
Swift
406 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 {
|
|
ZStack {
|
|
cellBackground
|
|
if item.mediaType == .video, image != nil {
|
|
videoOverlay.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
|
}
|
|
syncDot.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
|
}
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.clipped()
|
|
.id(taskID)
|
|
.task(id: taskID) { await loadThumbnail() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var cellBackground: some View {
|
|
if let img = image {
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.transition(.opacity.animation(.easeIn(duration: 0.18)))
|
|
} else {
|
|
Rectangle()
|
|
.fill(AppTheme.surfaceSunken)
|
|
.shimmer()
|
|
}
|
|
}
|
|
|
|
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()) }
|
|
}
|