SPB Git

spb/focale Public

Swift 100%
4.7 KB · 132 lines swift
Raw Blame History
1//2//  SearchView.swift3//  Focale4//5//  Author: Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//8//  Natural language in, ranked local results out. Generated descriptions9//  wear a visible badge — never dressed up as real data (CLAUDE.md §8).10//1112import SwiftUI1314struct SearchView: View {15    @Environment(AppModel.self) private var app1617    @State private var query = ""18    @State private var results: [SearchResult] = []19    @State private var isSearching = false20    @State private var hasSearched = false21    @State private var viewer: PhotoViewerContext?22    @State private var lastFilter: SearchFilter = .empty23    @State private var showsAlbumAlert = false24    @State private var albumName = ""2526    private let analyzer = QueryAnalyzer()27    private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]2829    var body: some View {30        NavigationStack {31            ScrollView {32                if isSearching {33                    ProgressView().padding(.top, 40)34                } else if results.isEmpty && hasSearched {35                    emptyState36                } else {37                    resultsGrid38                }39            }40            .background(DesignTokens.surface)41            .navigationTitle("Recherche")42            .searchable(43                text: $query,44                placement: .navigationBarDrawer(displayMode: .always),45                prompt: "« le reçu du garage l'automne passé »"46            )47            .onSubmit(of: .search) { runSearch() }48            .fullScreenCover(item: $viewer) { context in49                PhotoDetailView(context: context)50            }51            .toolbar {52                if !results.isEmpty && !lastFilter.isEmpty {53                    ToolbarItem(placement: .topBarTrailing) {54                        Button("Créer un album vivant", systemImage: "sparkles.rectangle.stack") {55                            albumName = query56                            showsAlbumAlert = true57                        }58                    }59                }60            }61            .alert("Album vivant", isPresented: $showsAlbumAlert) {62                TextField("Nom de l'album", text: $albumName)63                Button("Créer") {64                    let name = albumName.trimmingCharacters(in: .whitespacesAndNewlines)65                    if !name.isEmpty {66                        app.albums.add(LivingAlbum(name: name, filter: lastFilter))67                    }68                    albumName = ""69                }70                Button("Annuler", role: .cancel) { albumName = "" }71            } message: {72                Text("Cet album se remplira tout seul à mesure que tu photographies.")73            }74        }75    }7677    private var resultsGrid: some View {78        LazyVGrid(columns: columns, spacing: 2) {79            ForEach(results) { result in80                Button {81                    viewer = PhotoViewerContext(82                        id: result.localIdentifier,83                        identifiers: results.map(\.localIdentifier)84                    )85                } label: {86                    ZStack(alignment: .bottomLeading) {87                        AssetThumbnailView(localIdentifier: result.localIdentifier)88                            .aspectRatio(1, contentMode: .fill)89                            .clipped()90                        if result.gist != nil {91                            // Generated content is visually distinct, always.92                            GeneratedBadge()93                                .padding(4)94                        }95                    }96                }97                .buttonStyle(.plain)98            }99        }100    }101102    private var emptyState: some View {103        VStack(spacing: 10) {104            Image(systemName: "magnifyingglass")105                .font(.largeTitle)106                .foregroundStyle(DesignTokens.textSecondary)107            Text("Rien trouvé pour cette recherche")108                .font(.callout)109                .foregroundStyle(DesignTokens.textSecondary)110            Text("Les photos les plus récentes sont indexées en premier ; les plus anciennes arrivent au fil des nuits.")111                .font(.footnote)112                .foregroundStyle(DesignTokens.textSecondary)113                .multilineTextAlignment(.center)114                .padding(.horizontal, 30)115        }116        .padding(.top, 60)117    }118119    private func runSearch() {120        let text = query121        isSearching = true122        Task {123            // The model analyzes the query only — it never walks the photos.124            let filter = await analyzer.analyze(text)125            lastFilter = filter126            results = await app.searchEngine.search(filter)127            isSearching = false128            hasSearched = true129        }130    }131}132