Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app-core/Sources/AppCore/Catalogue/Catalogue.swift
Original file line number Diff line number Diff line change
@@ -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<Value>: DynamicProperty {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 5 additions & 37 deletions app-core/Sources/AppCore/Catalogue/CatalogueModel.swift
Original file line number Diff line number Diff line change
@@ -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<CatalogueCategory> = []

// 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() {}
}
193 changes: 164 additions & 29 deletions app-core/Sources/AppCore/Catalogue/CatalogueTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import TmbrCore

struct CatalogueTab: View {

@Environment(\.refreshCatalogue)
private var refreshCatalogue

@Environment(\.canAuthor)
private var canAuthor

Expand All @@ -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) {
Expand Down Expand Up @@ -57,7 +63,6 @@ struct CatalogueTab: View {
}
}
}
.task { await refreshCatalogue() }
#if os(iOS)
.sheet(isPresented: $showFilter) {
CatalogueFilterView()
Expand All @@ -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<CatalogueCategory>

@Environment(\.refreshCatalogue)
private var refreshCatalogue
private let selectedLanguages: Set<Language>

init(selectedCategories: Set<CatalogueCategory>, selectedLanguages: Set<Language>) {
_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<CatalogueCategory>,
selectedLanguages: Set<Language>
) {
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)
}
}
}
19 changes: 0 additions & 19 deletions app-core/Sources/AppCore/Catalogue/RefreshCatalogueAction.swift

This file was deleted.

3 changes: 1 addition & 2 deletions app-core/Sources/AppCore/Catalogue/View+Catalogue.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
30 changes: 0 additions & 30 deletions app-core/Sources/AppCore/Catalogue/Views/CatalogueEmptyView.swift

This file was deleted.

Loading