feat: redesign Gallery as photo timeline with sync state, deduplication, and detail view

- GalleryItem: unified model with phone/nas/synced source, sync state enum
- GalleryViewModel: off-actor PHFetchResult + manifest merge, Phone/NAS/All tab filtering, date section grouping, Combine-driven reactivity
- GalleryView: sticky date section headers, 3-col grid, shimmer placeholders, sync dots (green/orange/blue), video duration badge, search bar, context menu (Share, Save to Photos, Delete from Device)
- GalleryDetailView: full-screen swipeable photo viewer (TabView page mode), pinch-to-zoom, double-tap zoom toggle, bottom metadata sheet with file info and actions
- BackupStatusService: expose lastManifest for gallery deduplication without extra NAS connection
- ThumbnailCache: add decodeOffThread static helper for off-actor JPEG decode
- BackupManifest: Sendable conformance for safe Task.detached capture
- ThumbnailCache: synced items load from PHImageManager (instant) instead of NAS

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 21:30:31 +03:00
parent 5b68fde62f
commit 025326f2b2
8 changed files with 939 additions and 347 deletions

View File

@@ -0,0 +1,319 @@
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)
}
}
}

View File

@@ -2,31 +2,13 @@ import SwiftUI
import Photos
import UIKit
private let imageExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"]
private let videoExts: Set<String> = ["mp4","mov","m4v","avi","mkv"]
enum GallerySource { case nas, local }
struct GalleryView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@EnvironmentObject var statusService: BackupStatusService
@StateObject private var viewModel = GalleryViewModel()
@State private var source: GallerySource = .nas
// NAS state
@State private var nasItems: [NASItem] = []
@State private var nasLoading = false
@State private var nasError: String? = nil
// Local state
@State private var localAssets: [PHAsset] = []
@State private var syncedNames: Set<String> = []
@State private var localAuthDenied = false
@State private var appeared = false
private var connection: NASConnection? { store.savedConnection }
private let deviceName = UIDevice.current.name
@State private var selectedItem: GalleryItem?
@State private var searchVisible = false
private let columns = [
GridItem(.flexible(), spacing: 2),
@@ -35,395 +17,388 @@ struct GalleryView: View {
]
var body: some View {
ZStack {
ZStack(alignment: .top) {
AppTheme.background.ignoresSafeArea()
switch source {
case .nas: nasContent
case .local: localContent
VStack(spacing: 0) {
tabBar
if searchVisible { searchBar }
mainContent
}
}
.navigationTitle("")
.navigationTitle("Gallery")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { sourceChip }
.toolbar { trailingItems }
.fullScreenCover(item: $selectedItem) { item in
GalleryDetailView(
item: item,
allItems: viewModel.allItems,
connection: store.savedConnection
)
}
.task {
await loadNAS()
loadLocal()
await viewModel.load(connection: store.savedConnection, statusService: statusService)
}
.refreshable {
await loadNAS()
loadLocal()
await viewModel.load(connection: store.savedConnection, statusService: statusService)
}
}
// MARK: Source chip / dropdown
// MARK: Toolbar
private var sourceChip: some View {
Menu {
private var trailingItems: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas }
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
searchVisible.toggle()
if !searchVisible { viewModel.searchText = "" }
}
} label: {
Label(connection?.host ?? "NAS", systemImage: "externaldrive.fill")
}
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .local }
} label: {
Label(deviceName, systemImage: "iphone")
}
} label: {
HStack(spacing: 5) {
Circle()
.fill(dotColor)
.frame(width: 6, height: 6)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dotColor == AppTheme.positive)
Text(source == .nas ? (connection?.host ?? "NAS") : deviceName)
.font(.system(size: 12, weight: .medium))
Image(systemName: searchVisible ? "xmark.circle.fill" : "magnifyingglass")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(AppTheme.surfaceSunken)
.clipShape(Capsule(style: .continuous))
}
}
private var dotColor: Color {
switch source {
case .nas:
switch lanMonitor.nasReachable {
case true: return AppTheme.positive
case false: return AppTheme.destructive
case nil: return AppTheme.inkQuaternary
}
case .local:
return AppTheme.interactive
}
}
// MARK: NAS content
private var nasContent: some View {
Group {
if connection == nil {
placeholder(icon: "externaldrive.badge.xmark", text: "No NAS connected")
} else if nasLoading && nasItems.isEmpty {
loadingView("Loading gallery…")
} else if let err = nasError {
errorView(err) { Task { await loadNAS() } }
} else if nasItems.isEmpty {
placeholder(icon: "photo.on.rectangle", text: "No photos backed up yet")
} else {
gallery(items: nasItems.map { .nas($0) })
}
}
}
// MARK: Local content
// MARK: Tab bar
private var localContent: some View {
Group {
if localAuthDenied {
placeholder(icon: "lock.slash", text: "Photo library access denied")
} else if localAssets.isEmpty {
loadingView("Loading photos…")
} else {
gallery(items: localAssets.map { .local($0) })
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: Shared grid
// MARK: Search
private func gallery(items: [GalleryItem]) -> some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(Array(items.enumerated()), id: \.offset) { idx, item in
GalleryCell(
item: item,
connection: connection,
isSynced: syncStatus(for: item)
)
.opacity(appeared ? 1 : 0)
.animation(
.spring(response: 0.35, dampingFraction: 0.82)
.delay(Double(min(idx, 18)) * 0.02),
value: appeared
)
}
}
} header: {
gridHeader(count: items.count)
}
}
}
.onAppear { appeared = true }
}
private func gridHeader(count: Int) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(count) files")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Group {
switch source {
case .nas:
if let conn = connection {
Text((conn.remotePath as NSString).lastPathComponent)
}
case .local:
Text(deviceName)
}
}
.font(AppTheme.micro())
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()
if nasLoading {
ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary)
} 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)
}
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 10)
.background(AppTheme.background)
}
// MARK: Sync status
private func syncStatus(for item: GalleryItem) -> SyncStatus {
guard source == .local else { return .none }
switch item {
case .local(let asset):
let name = PHAssetResource.assetResources(for: asset)
.first?.originalFilename.lowercased() ?? ""
return syncedNames.contains(name) ? .synced : .pending
case .nas:
return .none
Spacer()
} else {
photoGrid
}
}
// MARK: Data
private func loadNAS() async {
guard let conn = connection else { return }
nasLoading = true
nasError = nil
do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: conn.host, port: conn.port,
username: conn.username, password: conn.password)
nasItems = try await s.listDirectory(at: conn.remotePath)
.filter { !$0.isDirectory }
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
s.disconnect()
syncedNames = Set(nasItems.map { $0.name.lowercased() })
} catch let e as BackupError {
nasError = e.errorDescription
} catch {
nasError = error.localizedDescription
private var emptyIcon: String {
switch viewModel.tab {
case .all, .phone: "photo.on.rectangle.angled"
case .nas: "externaldrive.badge.questionmark"
}
nasLoading = false
}
private func loadLocal() {
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
guard status == .authorized || status == .limited else {
if status == .denied || status == .restricted { localAuthDenied = true }
PHPhotoLibrary.requestAuthorization(for: .readWrite) { granted in
Task { @MainActor in
if granted == .authorized || granted == .limited {
loadLocalAssets()
} else {
localAuthDenied = true
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)
}
}
}
return
}
loadLocalAssets()
}
private func loadLocalAssets() {
let opts = PHFetchOptions()
opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
let result = PHAsset.fetchAssets(with: opts)
var assets: [PHAsset] = []
result.enumerateObjects { asset, _, _ in assets.append(asset) }
localAssets = assets
}
// MARK: Utility views
private func loadingView(_ text: String) -> some View {
VStack(spacing: 10) {
ProgressView().tint(AppTheme.inkTertiary)
Text(text).font(AppTheme.caption()).foregroundStyle(AppTheme.inkTertiary)
}
}
private func placeholder(icon: String, text: String) -> some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 32, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
Text(text)
.font(AppTheme.caption())
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)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 7)
.background(.ultraThinMaterial)
}
private func errorView(_ message: String, retry: @escaping () -> Void) -> some View {
VStack(spacing: 16) {
placeholder(icon: "wifi.exclamationmark", text: message)
Button(action: retry) {
Text("Retry")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.ink)
.padding(.horizontal, 16).padding(.vertical, 8)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
// 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)
}
}
.buttonStyle(ScaleButtonStyle())
}
}
}
// MARK: Gallery item model
enum GalleryItem {
case nas(NASItem)
case local(PHAsset)
}
enum SyncStatus { case none, synced, pending }
// MARK: Cell
struct GalleryCell: View {
let item: GalleryItem
let connection: NASConnection?
let isSynced: SyncStatus
@State private var image: UIImage? = nil
@State private var loading = false
@State private var image: UIImage?
@State private var taskID = UUID()
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if let img = image {
Image(uiImage: img)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeIn(duration: 0.18)))
} else {
Rectangle()
.fill(AppTheme.surfaceSunken)
.overlay {
if loading {
ProgressView().scaleEffect(0.55).tint(AppTheme.inkQuaternary)
} else {
Image(systemName: cellIcon)
.font(.system(size: 18, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
}
}
// Video badge
if isVideo && image != nil {
Image(systemName: "play.fill")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white)
.padding(3)
.background(.black.opacity(0.45))
.clipShape(Circle())
.padding(5)
}
// Sync status badge (local mode only)
if isSynced != .none {
Circle()
.fill(isSynced == .synced ? AppTheme.positive : Color(red: 1.0, green: 0.58, blue: 0.0))
.frame(width: 9, height: 9)
.overlay(Circle().stroke(Color.white, lineWidth: 1.5))
.padding(5)
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSynced == .synced)
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()
.task { await load() }
.id(taskID)
.task(id: taskID) { await loadThumbnail() }
}
private var isVideo: Bool {
switch item {
case .nas(let n): return videoExts.contains((n.name as NSString).pathExtension.lowercased())
case .local(let a): return a.mediaType == .video
@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 cellIcon: String {
switch item {
case .nas(let n):
let ext = (n.name as NSString).pathExtension.lowercased()
return videoExts.contains(ext) ? "play.rectangle" : "photo"
case .local(let a):
return a.mediaType == .video ? "play.rectangle" : "photo"
}
}
private func load() async {
guard image == nil, !loading else { return }
switch item {
case .nas(let nasItem):
guard let conn = connection else { return }
let ext = (nasItem.name as NSString).pathExtension.lowercased()
guard imageExts.contains(ext) || videoExts.contains(ext) else { return }
if let cached = ThumbnailCache.shared.get(for: nasItem.path) { image = cached; return }
loading = true
do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: conn.host, port: conn.port,
username: conn.username, password: conn.password)
let data = try await s.downloadData(at: nasItem.path)
s.disconnect()
if let img = UIImage(data: data)?.thumbnailScaled(to: 300) {
ThumbnailCache.shared.set(img, for: nasItem.path)
image = img
}
} catch {}
loading = false
case .local(let asset):
loading = true
let opts = PHImageRequestOptions()
opts.deliveryMode = .opportunistic
opts.isNetworkAccessAllowed = true
opts.isSynchronous = false
let size = CGSize(width: 300, height: 300)
await withCheckedContinuation { cont in
PHImageManager.default().requestImage(
for: asset, targetSize: size,
contentMode: .aspectFill, options: opts
) { img, info in
if let img { image = img }
let degraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false
if !degraded { cont.resume() }
}
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)
}
loading = false
}
.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()) }
}

View File

@@ -0,0 +1,227 @@
import Foundation
import Photos
import UIKit
import Combine
private let imageExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"]
private let videoExts: Set<String> = ["mp4","mov","m4v","avi","mkv"]
@MainActor
final class GalleryViewModel: ObservableObject {
enum Tab: String, CaseIterable { case all = "All", phone = "Phone", nas = "NAS" }
@Published var tab: Tab = .all
@Published var searchText = ""
@Published private(set) var sections: [GallerySection] = []
@Published private(set) var isLoading = false
@Published private(set) var nasError: String?
private(set) var allItems: [GalleryItem] = []
private var loadTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
init() {
$tab
.dropFirst()
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.recomputeSections() }
.store(in: &cancellables)
$searchText
.dropFirst()
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
.sink { [weak self] _ in self?.recomputeSections() }
.store(in: &cancellables)
}
func load(connection: NASConnection?, statusService: BackupStatusService) async {
loadTask?.cancel()
isLoading = true
nasError = nil
let manifestEntries = statusService.lastManifest?.entries ?? []
let phoneAssets = await Task.detached(priority: .userInitiated) {
Self.fetchPhoneAssets()
}.value
let items = await Task.detached(priority: .userInitiated) {
Self.buildItems(phone: phoneAssets, manifest: manifestEntries)
}.value
allItems = items
recomputeSections()
isLoading = false
}
func recomputeSections() {
let filtered = filteredItems()
sections = buildSections(from: filtered)
}
// MARK: Static off-actor workers
private nonisolated static func fetchPhoneAssets()
-> [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)]
{
let opts = PHFetchOptions()
opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
opts.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared, .typeiTunesSynced]
let result = PHAsset.fetchAssets(with: opts)
var out: [(String, String, Date?, TimeInterval?, Bool)] = []
out.reserveCapacity(result.count)
result.enumerateObjects { asset, _, _ in
let resources = PHAssetResource.assetResources(for: asset)
let filename = resources.first?.originalFilename
?? "\(asset.localIdentifier.prefix(8)).jpg"
let isVideo = asset.mediaType == .video
out.append((
asset.localIdentifier,
filename,
asset.creationDate,
isVideo ? asset.duration : nil,
isVideo
))
}
return out
}
private nonisolated static func buildItems(
phone: [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)],
manifest: [ManifestEntry]
) -> [GalleryItem] {
var byID = [String: ManifestEntry](minimumCapacity: manifest.count)
var byName = [String: ManifestEntry](minimumCapacity: manifest.count)
for entry in manifest {
if !entry.localIdentifier.isEmpty { byID[entry.localIdentifier] = entry }
let key = entry.filename.lowercased()
if byName[key] == nil { byName[key] = entry }
}
var result: [GalleryItem] = []
result.reserveCapacity(phone.count + manifest.count / 4)
var coveredFilenames = Set<String>()
for (localID, filename, date, duration, isVideo) in phone {
let key = filename.lowercased()
if let entry = byID[localID] ?? byName[key] {
coveredFilenames.insert(entry.filename.lowercased())
result.append(GalleryItem(
id: "synced_\(localID)",
filename: filename,
creationDate: date,
duration: duration,
fileSize: entry.fileSize > 0 ? entry.fileSize : nil,
mediaType: isVideo ? .video : .photo,
source: .synced(localIdentifier: localID, nasPath: entry.remotePath)
))
} else {
result.append(GalleryItem(
id: "phone_\(localID)",
filename: filename,
creationDate: date,
duration: duration,
fileSize: nil,
mediaType: isVideo ? .video : .photo,
source: .phone(localIdentifier: localID)
))
}
}
// NAS-only: in manifest but not matched to a phone asset
for entry in manifest {
guard !coveredFilenames.contains(entry.filename.lowercased()) else { continue }
let ext = (entry.filename as NSString).pathExtension.lowercased()
let mediaType: GalleryItem.MediaType =
videoExts.contains(ext) ? .video : imageExts.contains(ext) ? .photo : .unknown
result.append(GalleryItem(
id: "nas_\(entry.remotePath)",
filename: entry.filename,
creationDate: entry.creationDate,
duration: nil,
fileSize: entry.fileSize > 0 ? entry.fileSize : nil,
mediaType: mediaType,
source: .nas(path: entry.remotePath)
))
}
result.sort { ($0.creationDate ?? .distantPast) > ($1.creationDate ?? .distantPast) }
return result
}
// MARK: Filtering + grouping
private func filteredItems() -> [GalleryItem] {
var base: [GalleryItem]
switch tab {
case .all: base = allItems
case .phone: base = allItems.filter { if case .nas = $0.source { return false }; return true }
case .nas: base = allItems.filter { if case .phone = $0.source { return false }; return true }
}
let q = searchText.trimmingCharacters(in: .whitespaces).lowercased()
if !q.isEmpty { base = base.filter { $0.filename.lowercased().contains(q) } }
return base
}
private func buildSections(from items: [GalleryItem]) -> [GallerySection] {
let cal = Calendar.current
let currentYear = cal.component(.year, from: Date())
var sections: [GallerySection] = []
var indexMap: [String: Int] = [:]
for item in items {
let date = item.creationDate ?? .distantPast
let sectionID: String
let title: String
if item.creationDate == nil {
sectionID = "unknown"
title = "Unknown Date"
} else {
let comps = cal.dateComponents([.year, .month, .day], from: date)
sectionID = String(format: "%04d-%02d-%02d",
comps.year ?? 0, comps.month ?? 0, comps.day ?? 0)
let year = comps.year ?? currentYear
if year == currentYear {
title = date.formatted(.dateTime.month(.wide).day())
} else {
title = date.formatted(.dateTime.month(.wide).day().year())
}
}
if let idx = indexMap[sectionID] {
sections[idx].items.append(item)
} else {
indexMap[sectionID] = sections.count
sections.append(GallerySection(id: sectionID, date: date, title: title, items: [item]))
}
}
return sections
}
// MARK: Actions
func storeLocally(item: GalleryItem, connection: NASConnection?) async {
guard case .nas(let path) = item.source, let conn = connection else { return }
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()
let ext = (item.filename as NSString).pathExtension.lowercased()
let isVideo = videoExts.contains(ext)
try await PHPhotoLibrary.shared().performChanges {
if isVideo {
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(item.filename)
try? data.write(to: tmpURL)
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tmpURL)
} else if let img = UIImage(data: data) {
PHAssetChangeRequest.creationRequestForAsset(from: img)
}
}
} catch {}
}
}