spb/focale Public
Swift 100%
1//2// LibraryView.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Timeline of the system library (by localIdentifier, nothing duplicated)9// with the honest indexing banner: usable results now, transparent10// progress, never an opaque 20-hour spinner (CLAUDE.md §5).11//1213import Photos14import SwiftUI1516struct LibraryView: View {17 @Environment(AppModel.self) private var app1819 @State private var assetIdentifiers: [String] = []20 @State private var progress: IndexProgress?21 @State private var viewer: PhotoViewerContext?2223 private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]2425 var body: some View {26 NavigationStack {27 ScrollView {28 if let progress, progress.indexed < progress.total {29 indexingBanner(progress)30 }3132 livingAlbumsRow3334 LazyVGrid(columns: columns, spacing: 2) {35 ForEach(assetIdentifiers, id: \.self) { identifier in36 Button {37 viewer = PhotoViewerContext(38 id: identifier, identifiers: assetIdentifiers39 )40 } label: {41 AssetThumbnailView(localIdentifier: identifier)42 .aspectRatio(1, contentMode: .fill)43 .clipped()44 }45 .buttonStyle(.plain)46 .accessibilityIdentifier("photo-thumbnail")47 }48 }49 }50 .background(DesignTokens.surface)51 .navigationTitle("Bibliothèque")52 .fullScreenCover(item: $viewer) { context in53 PhotoDetailView(context: context)54 }55 .task {56 loadAssets()57 progress = await app.indexPipeline.progress()58 }59 }60 }6162 private func indexingBanner(_ progress: IndexProgress) -> some View {63 HStack(spacing: 10) {64 ProgressView(value: Double(progress.indexed), total: Double(max(progress.total, 1)))65 .frame(width: 60)66 Text(progress.statusLine)67 .font(.footnote)68 .foregroundStyle(DesignTokens.textSecondary)69 Spacer()70 }71 .padding(12)72 .background(DesignTokens.surfaceRaised, in: RoundedRectangle(73 cornerRadius: DesignTokens.cornerRadius74 ))75 .padding(.horizontal, 12)76 .padding(.top, 4)77 }7879 private var livingAlbumsRow: some View {80 ScrollView(.horizontal, showsIndicators: false) {81 HStack(spacing: 10) {82 ForEach(app.albums.albums) { album in83 NavigationLink {84 LivingAlbumView(album: album)85 } label: {86 Label(album.name, systemImage: "sparkles.rectangle.stack")87 .font(.footnote.weight(.medium))88 .padding(.horizontal, 12)89 .padding(.vertical, 8)90 .background(DesignTokens.surfaceRaised, in: Capsule())91 .foregroundStyle(.white)92 }93 }94 }95 .padding(.horizontal, 12)96 .padding(.vertical, 8)97 }98 }99100 /// Newest first — the index and the eye agree on what matters.101 private func loadAssets() {102 let options = PHFetchOptions()103 options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]104 options.fetchLimit = 600105 let fetched = PHAsset.fetchAssets(with: .image, options: options)106 var identifiers: [String] = []107 identifiers.reserveCapacity(fetched.count)108 fetched.enumerateObjects { asset, _, _ in109 identifiers.append(asset.localIdentifier)110 }111 assetIdentifiers = identifiers112 }113}114115/// A living album fills itself from its query (CLAUDE.md §7).116struct LivingAlbumView: View {117 @Environment(AppModel.self) private var app118 let album: LivingAlbum119120 @State private var results: [SearchResult] = []121 @State private var viewer: PhotoViewerContext?122123 private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]124125 var body: some View {126 ScrollView {127 LazyVGrid(columns: columns, spacing: 2) {128 ForEach(results) { result in129 Button {130 viewer = PhotoViewerContext(131 id: result.localIdentifier,132 identifiers: results.map(\.localIdentifier)133 )134 } label: {135 AssetThumbnailView(localIdentifier: result.localIdentifier)136 .aspectRatio(1, contentMode: .fill)137 .clipped()138 }139 .buttonStyle(.plain)140 }141 }142 }143 .background(DesignTokens.surface)144 .navigationTitle(album.name)145 .fullScreenCover(item: $viewer) { context in146 PhotoDetailView(context: context)147 }148 .task {149 results = await app.searchEngine.search(album.filter)150 }151 }152}153154/// Thumbnails straight from PhotoKit — never stored twice (CLAUDE.md §9).155struct AssetThumbnailView: View {156 let localIdentifier: String157 @State private var image: UIImage?158 @Environment(\.displayScale) private var displayScale159160 var body: some View {161 GeometryReader { geometry in162 ZStack {163 DesignTokens.surfaceRaised164 if let image {165 Image(uiImage: image)166 .resizable()167 .scaledToFill()168 }169 }170 .task(id: localIdentifier) {171 image = await Self.loadThumbnail(172 localIdentifier: localIdentifier,173 size: geometry.size,174 scale: displayScale175 )176 }177 }178 }179180 private static func loadThumbnail(181 localIdentifier: String, size: CGSize, scale: CGFloat182 ) async -> UIImage? {183 let fetched = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)184 guard let asset = fetched.firstObject else { return nil }185 let target = CGSize(width: size.width * scale, height: size.height * scale)186 return await withCheckedContinuation { continuation in187 let options = PHImageRequestOptions()188 options.deliveryMode = .opportunistic189 options.isNetworkAccessAllowed = true190 var resumed = false191 PHImageManager.default().requestImage(192 for: asset, targetSize: target, contentMode: .aspectFill, options: options193 ) { image, info in194 let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false195 if !degraded, !resumed {196 resumed = true197 continuation.resume(returning: image)198 }199 }200 }201 }202}203