From 154366d346174674954b05f604a881b54edefc03 Mon Sep 17 00:00:00 2001 From: Daniel Tombor Date: Fri, 10 Jul 2026 12:57:28 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Catalogue=20tab=20on=20the=20Feed=20eng?= =?UTF-8?q?ine=20=E2=80=94=20sync=20feed=20+=20search=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue hosts two feeds: browse refresh runs the SyncGroup through an input-less .catalogue feed (status line, pull-to-refresh, empty view + retry all ride FeedState), and search+paging rides the .catalogueSearch feed with its typed CatalogueSearchQuery (term + category types + languages, all sent to the server). - Pull-to-refresh restarts page 1 of the current session — sync for browse, the active search otherwise; fetches fire immediately (model supersedes) - The flash-fix choreography collapses to one task keyed on the search query (filter changes restart it) plus one revision observer for load-more upserts - Two feeds share the subtree, so the list scopes its components explicitly via .feedControls; row-driven paging deliberately not applied — engine results aren't createdAt-ordered - CatalogueModel slims to the category filter; the old refresh action, env keys, and status/empty views retire Completes the Feed migration — tree now matches the reference branch. Co-Authored-By: Claude Opus 4.8 --- .../Sources/AppCore/Catalogue/Catalogue.swift | 2 +- .../Catalogue/CatalogueEnvironment.swift | 3 +- .../AppCore/Catalogue/CatalogueModel.swift | 42 +--- .../AppCore/Catalogue/CatalogueTab.swift | 193 +++++++++++++++--- .../Catalogue/RefreshCatalogueAction.swift | 19 -- .../AppCore/Catalogue/View+Catalogue.swift | 3 +- .../Catalogue/Views/CatalogueEmptyView.swift | 30 --- .../Catalogue/Views/CatalogueStatusLine.swift | 42 ---- 8 files changed, 173 insertions(+), 161 deletions(-) delete mode 100644 app-core/Sources/AppCore/Catalogue/RefreshCatalogueAction.swift delete mode 100644 app-core/Sources/AppCore/Catalogue/Views/CatalogueEmptyView.swift delete mode 100644 app-core/Sources/AppCore/Catalogue/Views/CatalogueStatusLine.swift diff --git a/app-core/Sources/AppCore/Catalogue/Catalogue.swift b/app-core/Sources/AppCore/Catalogue/Catalogue.swift index add62904..b6a308eb 100644 --- a/app-core/Sources/AppCore/Catalogue/Catalogue.swift +++ b/app-core/Sources/AppCore/Catalogue/Catalogue.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Scoped access to `CatalogueModel` (`@Catalogue(\.phase)`), mirroring `@Blog`. +/// Scoped access to `CatalogueModel` (`@Catalogue(\.phase)`), mirroring `@Quotes`. @MainActor @propertyWrapper public struct Catalogue: DynamicProperty { diff --git a/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift b/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift index 1651a10b..8d49cfaf 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift @@ -2,12 +2,13 @@ import SwiftUI import Foundation public extension EnvironmentValues { - @Entry var refreshCatalogue: CatalogueRefreshAction = CatalogueRefreshAction() + @Entry var selectCategories: SelectCategoriesAction = SelectCategoriesAction() // Networking config — `apiBaseURL == nil` is the on/off gate: Personal injects nothing // → @Loader returns nil, @Upserter returns a no-op syncer. @Entry var apiBaseURL: URL? = nil + @Entry var urlSession: URLSession = .shared // Network seam: factories keyed by item type. Tests/previews substitute a stub factory diff --git a/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift b/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift index 8f51f495..bfa254a5 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift @@ -1,48 +1,16 @@ import Foundation -import OSLog import AppPersistence -/// Headless model for the Catalogue tab — tracks refresh activity and outcome; the list comes from `@Query`. -/// The injected `refresh` closure is the per-app seam (Reader fetch+upsert / Author SyncEngine / no-op). +/// Headless model for the Catalogue tab — tracks the active category filter. +/// +/// Empty = no filter (show all). Browse refresh lives in the `.catalogue` feed +/// (`@Feed(.catalogue)`); search + paging in the `.catalogueSearch` feed. @MainActor @Observable public final class CatalogueModel { - // MARK: - Public state - - /// Non-nil while a refresh is in flight. - public private(set) var loading: LoadingState? - - /// The error from the last refresh attempt; `nil` after a successful refresh. - public private(set) var lastError: LoadError? - - /// When the last successful refresh completed. - public private(set) var lastFetched: Date? - /// Categories currently active in the filter. Empty = no filter (show all). public var selectedCategories: Set = [] - // MARK: - Dependencies - - private let _refresh: @Sendable () async throws -> Void - - public init(refresh: @escaping @Sendable () async throws -> Void = {}) { - self._refresh = refresh - } - - // MARK: - Refresh - - public func refresh() async { - guard loading == nil else { return } - loading = .refresh - defer { loading = nil } - do { - try await _refresh() - lastFetched = .now - lastError = nil - } catch { - Logger.catalogue.error("Catalogue refresh failed: \(error)") - lastError = LoadError(error) - } - } + public init() {} } diff --git a/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift b/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift index 89c6d7ba..6913e1a6 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift @@ -5,9 +5,6 @@ import TmbrCore struct CatalogueTab: View { - @Environment(\.refreshCatalogue) - private var refreshCatalogue - @Environment(\.canAuthor) private var canAuthor @@ -17,18 +14,27 @@ struct CatalogueTab: View { @Preferences(\.selectedLanguages) private var selectedLanguages + @State private var searchText = "" + @State private var showFilter = false + @State private var showTypePicker = false + @State private var selectedType: CatalogueItemType? + @State private var showEditor = false var body: some View { NavigationStack { - CatalogueItemsList( + CatalogueList( + term: searchText.isEmpty ? nil : searchText, selectedCategories: selectedCategories, selectedLanguages: selectedLanguages ) + .feed(.catalogue) + .feed(.catalogueSearch) .navigationTitle("Catalogue") + .searchable(text: $searchText, prompt: "Search catalogue…") .toolbar { #if os(iOS) ToolbarItem(placement: .topBarLeading) { @@ -57,7 +63,6 @@ struct CatalogueTab: View { } } } - .task { await refreshCatalogue() } #if os(iOS) .sheet(isPresented: $showFilter) { CatalogueFilterView() @@ -79,46 +84,176 @@ struct CatalogueTab: View { } } -private struct CatalogueItemsList: View { +// MARK: - Unified list + +/// A single list view that handles both browse (term == nil) and search (term != nil). +/// +/// Browse: `@Query` over `PreviewRecord`s filtered by category + language — auto-reactive to +/// SwiftData upserts; the `.catalogue` feed's refresh (SyncGroup) populates it. Search: +/// `CatalogueSearchEngine.results` (manual fetch) driven by the `.catalogueSearch` feed and the +/// task/onChange handlers below. +private struct CatalogueList: View { + + @Query private var browseItems: [PreviewRecord] + + /// Sync-refresh state for the status line, pull-to-refresh, and the empty view. + @Feed(CatalogueSyncQuery.self) + private var browseFeed + + /// Search + paging over `GET /api/catalogue/search`; `refresh()` is never called on it. + @Feed(CatalogueSearchQuery.self) + private var searchFeed + + @Environment(\.modelContext) + private var modelContext + + @State private var searchEngine = CatalogueSearchEngine() + + let term: String? - @Query private var items: [PreviewRecord] + private let selectedCategories: Set - @Environment(\.refreshCatalogue) - private var refreshCatalogue + private let selectedLanguages: Set - init(selectedCategories: Set, selectedLanguages: Set) { - _items = Query( + /// The current search input — term plus active filters; `nil` when not searching. + /// Also the search `.task` id, so any term or filter change restarts the flow. + private let searchQuery: CatalogueSearchQuery? + + init( + term: String?, + selectedCategories: Set, + selectedLanguages: Set + ) { + self.term = term + self.selectedCategories = selectedCategories + self.selectedLanguages = selectedLanguages + searchQuery = term.map { + CatalogueSearchQuery( + term: $0, + types: selectedCategories.apiValues, + languages: selectedLanguages.apiValues + ) + } + _browseItems = Query( filter: PreviewRecord.catalogue(categories: selectedCategories, languages: selectedLanguages), sort: \PreviewRecord.primaryInfo ) } + private var isSearching: Bool { term != nil } + + private var hasResults: Bool { + isSearching ? !searchEngine.results.isEmpty : !browseItems.isEmpty + } + var body: some View { List { - if !items.isEmpty { - CatalogueStatusLine() + if hasResults { FeedStatusLine() } + if isSearching { + searchRows + } else { + browseRows } - ForEach(items) { item in - NavigationLink { - CatalogueItemDetailView(item: item) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(item.primaryInfo) - if !item.secondaryInfo.isEmpty { - Text(item.secondaryInfo) - .font(.subheadline) - .foregroundStyle(.secondary) - } + } + .listStyle(.plain) + .overlay { + if isSearching { + if searchEngine.results.isEmpty { ContentUnavailableView.search } + } else { + if browseItems.isEmpty { + FeedEmptyView { + ContentUnavailableView("Nothing here yet", systemImage: "square.grid.2x2") } } } } - .overlay { - if items.isEmpty { - CatalogueEmptyView() + .navigationDestination(for: CatalogueItemNavigation.self) { CatalogueItemNavigation.destination($0) } + // Pull-to-refresh restarts page 1 of the current session — sync for browse, the + // active search otherwise. + .refreshable { + if let searchQuery { + await searchFeed.fetch(searchQuery) + } else { + await browseFeed.fetch() } } - .navigationDestination(for: CatalogueItemNavigation.self) { CatalogueItemNavigation.destination($0) } - .refreshable { await refreshCatalogue() } + .task { await browseFeed.fetch() } + // Flash fix, one task: run local search IMMEDIATELY for instant results, then fire the + // network fetch (the model supersedes in-flight requests, so the last input wins); a + // second local search picks up the upserted records. The task id restarts this on any + // term OR filter change, so the immediate local pass also covers what separate filter + // onChange handlers would do. On clear, the browse list re-appears automatically via + // the query predicate. + .task(id: searchQuery) { + guard let query = searchQuery else { + searchEngine.clear() + return + } + let term = query.term + searchEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + await searchFeed.fetch(query) + guard !Task.isCancelled else { return } + searchEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + } + // Re-run local search after a load-more upsert so new records appear immediately. + // Deliberately NOT part of the task id above: search bumps revision itself, so folding + // it in would restart the task and loop (search → revision += 1 → restart → search …). + .onChange(of: searchFeed.state.revision) { _, _ in + guard let term else { return } + searchEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + } + // Two feeds share this subtree, so the component keys would otherwise follow whichever + // `.feed(_:)` sits innermost — scope explicitly: status line, empty view, and retry + // follow the browse (sync) feed; the load-more cell in `searchRows` re-scopes itself + // to the search feed. + .feedControls(browseFeed) + } + + @ViewBuilder + private var browseRows: some View { + ForEach(browseItems) { item in + NavigationLink { + CatalogueItemDetailView(item: item) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(item.primaryInfo) + if !item.secondaryInfo.isEmpty { + Text(item.secondaryInfo) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + } + + @ViewBuilder + private var searchRows: some View { + ForEach(searchEngine.results) { result in + NavigationLink { + CatalogueItemDetailView(item: result.preview) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(result.preview.primaryInfo) + if let snippet = result.noteSnippet { + Text(snippet) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if !result.preview.secondaryInfo.isEmpty { + Text(result.preview.secondaryInfo) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + if !searchEngine.results.isEmpty { + // Innermost scope wins: this cell pages the search feed, not the browse sync. + // Row-driven paging (see PostsList) doesn't apply here — engine results aren't + // createdAt-ordered, so a row's date says nothing about the fetched frontier. + FeedLoadMoreCell() + .feedControls(searchFeed) + } } } diff --git a/app-core/Sources/AppCore/Catalogue/RefreshCatalogueAction.swift b/app-core/Sources/AppCore/Catalogue/RefreshCatalogueAction.swift deleted file mode 100644 index 20737aaf..00000000 --- a/app-core/Sources/AppCore/Catalogue/RefreshCatalogueAction.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -/// Refreshes the Catalogue tab — the env-injected seam. One key, a different body per app -/// (Reader fetch+upsert, Author `SyncEngine`, Personal no-op), all funnelled through `CatalogueModel`. -@MainActor -public struct CatalogueRefreshAction: Sendable { - - private let body: @MainActor () async -> Void - - nonisolated public init(_ body: @escaping @MainActor () async -> Void = {}) { - self.body = body - } - - public init(model: CatalogueModel) { - self.init { await model.refresh() } - } - - public func callAsFunction() async { await body() } -} diff --git a/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift b/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift index 048d840c..bc892438 100644 --- a/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift +++ b/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift @@ -1,10 +1,9 @@ import SwiftUI public extension View { - /// Injects the Catalogue model + its refresh action for the tab subtree. + /// Injects the Catalogue filter model + its category-selection action for the tab subtree. func catalogue(_ model: CatalogueModel) -> some View { environment(model) - .environment(\.refreshCatalogue, CatalogueRefreshAction(model: model)) .environment(\.selectCategories, SelectCategoriesAction(model: model)) } } diff --git a/app-core/Sources/AppCore/Catalogue/Views/CatalogueEmptyView.swift b/app-core/Sources/AppCore/Catalogue/Views/CatalogueEmptyView.swift deleted file mode 100644 index 8a5ebd09..00000000 --- a/app-core/Sources/AppCore/Catalogue/Views/CatalogueEmptyView.swift +++ /dev/null @@ -1,30 +0,0 @@ -import SwiftUI - -/// Overlay shown when the catalogue list is empty, switching on the current load state. -struct CatalogueEmptyView: View { - - @Catalogue(\.loading) - private var loading - - @Catalogue(\.lastError) - private var lastError - - @Environment(\.refreshCatalogue) - private var refreshCatalogue - - var body: some View { - if loading == .refresh { - ProgressView() - } else if let error = lastError { - ContentUnavailableView { - Label(error.title, systemImage: error.systemImage) - } description: { - Text(error.message) - } actions: { - Button("Try Again") { Task { await refreshCatalogue() } } - } - } else { - ContentUnavailableView("Nothing here yet", systemImage: "square.grid.2x2") - } - } -} diff --git a/app-core/Sources/AppCore/Catalogue/Views/CatalogueStatusLine.swift b/app-core/Sources/AppCore/Catalogue/Views/CatalogueStatusLine.swift deleted file mode 100644 index 9fb95473..00000000 --- a/app-core/Sources/AppCore/Catalogue/Views/CatalogueStatusLine.swift +++ /dev/null @@ -1,42 +0,0 @@ -import SwiftUI - -/// A non-disruptive one-liner row shown at the top of a populated catalogue list. -/// Displays refresh activity, a staleness indicator, or the last-updated time. -struct CatalogueStatusLine: View { - - @Catalogue(\.loading) - private var loading - - @Catalogue(\.lastError) - private var lastError - - @Catalogue(\.lastFetched) - private var lastFetched - - var body: some View { - statusContent - .font(.caption) - .foregroundStyle(.secondary) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - - @ViewBuilder - private var statusContent: some View { - if loading == .refresh { - Label("Updating\u{2026}", systemImage: "arrow.clockwise") - } else if lastError != nil { - if let date = lastFetched { - Label { - Text("Couldn't update \u{00B7} \(date, format: .relative(presentation: .named))") - } icon: { - Image(systemName: "exclamationmark.circle") - } - } else { - Label("Couldn't update", systemImage: "exclamationmark.circle") - } - } else if let date = lastFetched { - Text("Updated \(date, format: .relative(presentation: .named))") - } - } -}