SPB Git

spb/focale Public

Swift 100%
7.1 KB · 203 lines swift
Raw Blame History
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