// // LibraryView.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Timeline of the system library (by localIdentifier, nothing duplicated) // with the honest indexing banner: usable results now, transparent // progress, never an opaque 20-hour spinner (CLAUDE.md §5). // import Photos import SwiftUI struct LibraryView: View { @Environment(AppModel.self) private var app @State private var assetIdentifiers: [String] = [] @State private var progress: IndexProgress? @State private var viewer: PhotoViewerContext? private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)] var body: some View { NavigationStack { ScrollView { if let progress, progress.indexed < progress.total { indexingBanner(progress) } livingAlbumsRow LazyVGrid(columns: columns, spacing: 2) { ForEach(assetIdentifiers, id: \.self) { identifier in Button { viewer = PhotoViewerContext( id: identifier, identifiers: assetIdentifiers ) } label: { AssetThumbnailView(localIdentifier: identifier) .aspectRatio(1, contentMode: .fill) .clipped() } .buttonStyle(.plain) .accessibilityIdentifier("photo-thumbnail") } } } .background(DesignTokens.surface) .navigationTitle("Bibliothèque") .fullScreenCover(item: $viewer) { context in PhotoDetailView(context: context) } .task { loadAssets() progress = await app.indexPipeline.progress() } } } private func indexingBanner(_ progress: IndexProgress) -> some View { HStack(spacing: 10) { ProgressView(value: Double(progress.indexed), total: Double(max(progress.total, 1))) .frame(width: 60) Text(progress.statusLine) .font(.footnote) .foregroundStyle(DesignTokens.textSecondary) Spacer() } .padding(12) .background(DesignTokens.surfaceRaised, in: RoundedRectangle( cornerRadius: DesignTokens.cornerRadius )) .padding(.horizontal, 12) .padding(.top, 4) } private var livingAlbumsRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(app.albums.albums) { album in NavigationLink { LivingAlbumView(album: album) } label: { Label(album.name, systemImage: "sparkles.rectangle.stack") .font(.footnote.weight(.medium)) .padding(.horizontal, 12) .padding(.vertical, 8) .background(DesignTokens.surfaceRaised, in: Capsule()) .foregroundStyle(.white) } } } .padding(.horizontal, 12) .padding(.vertical, 8) } } /// Newest first — the index and the eye agree on what matters. private func loadAssets() { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] options.fetchLimit = 600 let fetched = PHAsset.fetchAssets(with: .image, options: options) var identifiers: [String] = [] identifiers.reserveCapacity(fetched.count) fetched.enumerateObjects { asset, _, _ in identifiers.append(asset.localIdentifier) } assetIdentifiers = identifiers } } /// A living album fills itself from its query (CLAUDE.md §7). struct LivingAlbumView: View { @Environment(AppModel.self) private var app let album: LivingAlbum @State private var results: [SearchResult] = [] @State private var viewer: PhotoViewerContext? private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)] var body: some View { ScrollView { LazyVGrid(columns: columns, spacing: 2) { ForEach(results) { result in Button { viewer = PhotoViewerContext( id: result.localIdentifier, identifiers: results.map(\.localIdentifier) ) } label: { AssetThumbnailView(localIdentifier: result.localIdentifier) .aspectRatio(1, contentMode: .fill) .clipped() } .buttonStyle(.plain) } } } .background(DesignTokens.surface) .navigationTitle(album.name) .fullScreenCover(item: $viewer) { context in PhotoDetailView(context: context) } .task { results = await app.searchEngine.search(album.filter) } } } /// Thumbnails straight from PhotoKit — never stored twice (CLAUDE.md §9). struct AssetThumbnailView: View { let localIdentifier: String @State private var image: UIImage? @Environment(\.displayScale) private var displayScale var body: some View { GeometryReader { geometry in ZStack { DesignTokens.surfaceRaised if let image { Image(uiImage: image) .resizable() .scaledToFill() } } .task(id: localIdentifier) { image = await Self.loadThumbnail( localIdentifier: localIdentifier, size: geometry.size, scale: displayScale ) } } } private static func loadThumbnail( localIdentifier: String, size: CGSize, scale: CGFloat ) async -> UIImage? { let fetched = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil) guard let asset = fetched.firstObject else { return nil } let target = CGSize(width: size.width * scale, height: size.height * scale) return await withCheckedContinuation { continuation in let options = PHImageRequestOptions() options.deliveryMode = .opportunistic options.isNetworkAccessAllowed = true var resumed = false PHImageManager.default().requestImage( for: asset, targetSize: target, contentMode: .aspectFill, options: options ) { image, info in let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false if !degraded, !resumed { resumed = true continuation.resume(returning: image) } } } } }