diff --git a/app-core/Sources/AppCore/Blog/Actions/BlogPageLoadAction.swift b/app-core/Sources/AppCore/Blog/Actions/BlogPageLoadAction.swift deleted file mode 100644 index bb502755..00000000 --- a/app-core/Sources/AppCore/Blog/Actions/BlogPageLoadAction.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// Loads the next page of Blog posts — the env-injected seam. Same shape as `RefreshBlogAction`: -/// a no-op default + a model-bound production init, callable as a function. -@MainActor -public struct BlogPageLoadAction: Sendable { - - private let body: @MainActor () async -> Void - - /// Default / test path — no model needed. - nonisolated public init(_ body: @escaping @MainActor () async -> Void = {}) { - self.body = body - } - - /// Production — bound to the screen's model. - public init(model: BlogModel) { - self.init { await model.loadMore() } - } - - public func callAsFunction() async { await body() } -} diff --git a/app-core/Sources/AppCore/Blog/Actions/BlogRefreshAction.swift b/app-core/Sources/AppCore/Blog/Actions/BlogRefreshAction.swift deleted file mode 100644 index 8f3d5c54..00000000 --- a/app-core/Sources/AppCore/Blog/Actions/BlogRefreshAction.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// Refreshes the Blog tab — the env-injected seam. One key, a different body per app -/// (Reader fetch+upsert, Author `SyncEngine`, Personal no-op), all funnelled through `BlogModel`. -@MainActor -public struct BlogRefreshAction: Sendable { - - private let body: @MainActor () async -> Void - - /// Default / test path — no model needed. - nonisolated public init(_ body: @escaping @MainActor () async -> Void = {}) { - self.body = body - } - - /// Production — bound to the screen's model. - public init(model: BlogModel) { - self.init { await model.refresh() } - } - - public func callAsFunction() async { await body() } -} diff --git a/app-core/Sources/AppCore/Blog/Blog.swift b/app-core/Sources/AppCore/Blog/Blog.swift deleted file mode 100644 index 544489e6..00000000 --- a/app-core/Sources/AppCore/Blog/Blog.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftUI -import SwiftData - -/// Scoped access to `BlogModel` — a view re-renders only for the keypath it reads (`@Blog(\.status)`), -/// not on every model change. Mirrors the house `@NowPlaying` wrapper. -@MainActor -@propertyWrapper -public struct Blog: DynamicProperty { - - @Environment(BlogModel.self) - private var model - - private let get: @MainActor (BlogModel) -> Value - - private let set: @MainActor (BlogModel, Value) -> Void - - public var wrappedValue: Value { - get { get(model) } - nonmutating set { set(model, newValue) } - } - - public var projectedValue: Binding { - Binding { get(model) } set: { set(model, $0) } - } - - public init(_ path: KeyPath) { - get = { $0[keyPath: path] } - set = { _, _ in } - } - - public init(_ path: ReferenceWritableKeyPath) { - get = { $0[keyPath: path] } - set = { model, value in model[keyPath: path] = value } - } -} diff --git a/app-core/Sources/AppCore/Blog/BlogEnvironment.swift b/app-core/Sources/AppCore/Blog/BlogEnvironment.swift deleted file mode 100644 index 4afd45d4..00000000 --- a/app-core/Sources/AppCore/Blog/BlogEnvironment.swift +++ /dev/null @@ -1,9 +0,0 @@ -import SwiftUI - -public extension EnvironmentValues { - @Entry - var refreshBlog: BlogRefreshAction = BlogRefreshAction() - - @Entry - var loadBlog: BlogPageLoadAction = BlogPageLoadAction() -} diff --git a/app-core/Sources/AppCore/Blog/BlogModel.swift b/app-core/Sources/AppCore/Blog/BlogModel.swift deleted file mode 100644 index 4c9c6eb7..00000000 --- a/app-core/Sources/AppCore/Blog/BlogModel.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation -import OSLog - -// `LoadingState` now lives with `FeedState` (Feed/FeedState.swift); this model reuses it. - -/// Headless model for the Blog tab. Tracks refresh activity and outcome; the list itself comes -/// from `@Query` in the view. -/// -/// The **per-app seam** is the pair of injected closures: Reader fetches public posts + upserts; -/// Author runs its `SyncEngine`; Personal is a no-op. `CoreApp` stays networking-free — it only -/// knows "run this async operation and track its state." -/// -/// `_refresh` returns the date when data was successfully fetched — or `nil` if not applicable -/// (Personal, no-op). Persistence of that date across launches is the caller's responsibility. -@MainActor -@Observable -public final class BlogModel { - - // MARK: - Public state - - /// Which load operation is currently in flight; `nil` when idle. - public private(set) var loading: LoadingState? - - /// `false` once a page fetch returns no cursor, meaning there are no more pages to load. - public private(set) var hasMore = true - - /// The error from the last refresh attempt; `nil` after a successful refresh. - public private(set) var lastError: LoadError? - - /// When the last successful refresh completed. Set from the value returned by `_refresh`. - /// Persisting this across launches is the caller's responsibility. - public private(set) var lastFetched: Date? - - // MARK: - Dependencies - - private let _refresh: @Sendable () async throws -> Date? - - private let _loadMore: @Sendable () async throws -> Bool - - public init( - refresh: @escaping @Sendable () async throws -> Date? = { nil }, - loadMore: @escaping @Sendable () async throws -> Bool = { false }, - lastFetched: Date? = nil - ) { - self._refresh = refresh - self._loadMore = loadMore - self.lastFetched = lastFetched - } - - // MARK: - Refresh (first page) - - public func refresh() async { - guard loading == nil else { return } - loading = .refresh - defer { loading = nil } - do { - if let date = try await _refresh() { - lastFetched = date - } - lastError = nil - hasMore = true // optimistic — first loadMore() will settle it - } catch { - Logger.blog.error("Blog refresh failed: \(error)") - lastError = LoadError(error) - } - } - - // MARK: - Load more (subsequent pages) - - /// Fetches the next page if one is available. Never surfaces its error to the UI — - /// a paging hiccup on a populated list shouldn't blank the screen. - public func loadMore() async { - guard hasMore, loading == nil else { return } - loading = .page - defer { loading = nil } - do { - hasMore = try await _loadMore() - } catch { - Logger.blog.error("Blog load-more failed: \(error)") - } - } -} diff --git a/app-core/Sources/AppCore/Blog/View+Blog.swift b/app-core/Sources/AppCore/Blog/View+Blog.swift deleted file mode 100644 index 85762f11..00000000 --- a/app-core/Sources/AppCore/Blog/View+Blog.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -public extension View { - /// Injects the Blog model + its refresh action (the seam) for the tab subtree. - func blog(_ model: BlogModel) -> some View { - environment(model) - .environment(\.refreshBlog, BlogRefreshAction(model: model)) - .environment(\.loadBlog, BlogPageLoadAction(model: model)) - } -} diff --git a/app-core/Sources/AppCore/Blog/Views/BlogEmptyView.swift b/app-core/Sources/AppCore/Blog/Views/BlogEmptyView.swift deleted file mode 100644 index e043c97e..00000000 --- a/app-core/Sources/AppCore/Blog/Views/BlogEmptyView.swift +++ /dev/null @@ -1,30 +0,0 @@ -import SwiftUI - -/// Overlay shown when the post list is empty, switching on the current load state. -struct BlogEmptyView: View { - - @Blog(\.loading) - private var activeLoad - - @Blog(\.lastError) - private var lastError - - @Environment(\.refreshBlog) - private var refreshBlog - - var body: some View { - if activeLoad == .refresh { - ProgressView() - } else if let error = lastError { - ContentUnavailableView { - Label(error.title, systemImage: error.systemImage) - } description: { - Text(error.message) - } actions: { - Button("Try Again") { Task { await refreshBlog() } } - } - } else { - ContentUnavailableView("No posts yet", systemImage: "doc.text") - } - } -} diff --git a/app-core/Sources/AppCore/Blog/Views/BlogLoadMoreCell.swift b/app-core/Sources/AppCore/Blog/Views/BlogLoadMoreCell.swift deleted file mode 100644 index 8e156808..00000000 --- a/app-core/Sources/AppCore/Blog/Views/BlogLoadMoreCell.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SwiftUI - -/// A list footer that owns the load-more trigger and loading spinner. -/// -/// Fires `loadBlog` the moment it appears (the model guards against redundant calls when -/// `!hasMore` or `isPageLoading`). Renders a centered `ProgressView` while loading, and an -/// invisible placeholder otherwise so the `.onAppear` trigger stays in the list. -struct BlogLoadMoreCell: View { - - @Blog(\.loading) - private var loading - - @Blog(\.hasMore) - private var hasMore - - @Environment(\.loadBlog) - private var loadBlog - - var body: some View { - Group { - if loading == .page { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else { - Color.clear.frame(height: 0) - } - } - .listRowSeparator(.hidden) - .onAppear { - Task { await loadBlog() } - } - } -} diff --git a/app-core/Sources/AppCore/Blog/Views/BlogStatusLine.swift b/app-core/Sources/AppCore/Blog/Views/BlogStatusLine.swift deleted file mode 100644 index 69a8183a..00000000 --- a/app-core/Sources/AppCore/Blog/Views/BlogStatusLine.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SwiftUI - -/// A non-disruptive one-liner row shown at the top of a populated post list. -/// Displays refresh activity, a staleness indicator, or the last-updated time. -/// -/// Uses `Text(date, format:)` for relative dates so the time auto-updates while the view is on screen. -struct BlogStatusLine: View { - - @Blog(\.loading) - private var activeLoad - - @Blog(\.lastError) - private var lastError - - @Blog(\.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 activeLoad == .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))") - } - // else: EmptyView — no status to show yet - } -} diff --git a/app-core/Sources/AppCore/Quotes/QuotesEmptyView.swift b/app-core/Sources/AppCore/Quotes/QuotesEmptyView.swift deleted file mode 100644 index fcb67aa1..00000000 --- a/app-core/Sources/AppCore/Quotes/QuotesEmptyView.swift +++ /dev/null @@ -1,30 +0,0 @@ -import SwiftUI - -/// Overlay shown when the quotes list is empty, switching on the current catalogue load state. -struct QuotesEmptyView: 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: "quote.bubble") - } - } -} diff --git a/app-core/Sources/AppCore/Quotes/QuotesModel.swift b/app-core/Sources/AppCore/Quotes/QuotesModel.swift index cb0478bc..66a36741 100644 --- a/app-core/Sources/AppCore/Quotes/QuotesModel.swift +++ b/app-core/Sources/AppCore/Quotes/QuotesModel.swift @@ -9,7 +9,7 @@ import AppPersistence /// /// This is intentionally decoupled from `CatalogueModel` — the Quotes filter is a /// superset of catalogue categories (it also spans blog posts), so the two tabs maintain -/// independent selections. +/// independent selections. Search + paging live in the quotes `FeedModel` (`@Feed(.quotes)`). @MainActor @Observable public final class QuotesModel { diff --git a/app-core/Sources/AppCore/Quotes/QuotesTab.swift b/app-core/Sources/AppCore/Quotes/QuotesTab.swift index c8eff726..2308cb65 100644 --- a/app-core/Sources/AppCore/Quotes/QuotesTab.swift +++ b/app-core/Sources/AppCore/Quotes/QuotesTab.swift @@ -5,29 +5,27 @@ import TmbrCore struct QuotesTab: View { - @Environment(\.refreshCatalogue) - private var refreshCatalogue - - @Environment(\.refreshBlog) - private var refreshBlog - @Quotes(\.selectedSources) private var selectedSources @Preferences(\.selectedLanguages) private var selectedLanguages + @State private var searchText = "" @State private var showFilter = false @State private var showRandom = false var body: some View { NavigationStack { QuotesList( - selectedSources: selectedSources, - selectedLanguages: selectedLanguages, + languages: selectedLanguages, + sources: selectedSources, + searchText: searchText, showRandom: $showRandom ) + .feed(.quotes) .navigationTitle("Quotes") + .searchable(text: $searchText, prompt: "Search quotes…") .toolbar { #if os(iOS) ToolbarItem(placement: .topBarLeading) { @@ -60,10 +58,6 @@ struct QuotesTab: View { } } } - .task { - await refreshCatalogue() - await refreshBlog() - } #if os(iOS) .sheet(isPresented: $showFilter) { QuotesFilterView() @@ -72,45 +66,104 @@ struct QuotesTab: View { } } +// MARK: - Unified list (browse and search) + private struct QuotesList: View { - @Query private var quotes: [QuoteRecord] + @Feed(QuotesQuery.self) + private var feed + + /// The view's paging state — the source of truth for what to fetch next. Reset on every + /// new intent, updated from the receipt of each committed fetch. + @State + private var page: FeedPage? - @Environment(\.refreshCatalogue) - private var refreshCatalogue + @Query + private var quotes: [QuoteRecord] - @Environment(\.refreshBlog) - private var refreshBlog + @Binding + private var showRandom: Bool - @Binding private var showRandom: Bool + private let languages: Set - init(selectedSources: Set, selectedLanguages: Set, showRandom: Binding) { + private let sources: Set + + private let searchText: String + + init( + languages: Set, + sources: Set, + searchText: String, + showRandom: Binding + ) { + self.languages = languages + self.sources = sources + self.searchText = searchText + _showRandom = showRandom _quotes = Query( - filter: QuoteRecord.list(sources: selectedSources, languages: selectedLanguages), + filter: QuoteRecord.quotes(term: searchText, sources: sources, languages: languages), sort: \QuoteRecord.createdAt, order: .reverse ) - _showRandom = showRandom } var body: some View { List { ForEach(quotes) { quote in QuoteCell(quote: quote) + .onAppear { [createdAt = quote.createdAt] in + Task { await fetch(after: createdAt) } + } + } + if !quotes.isEmpty { + FeedLoadMoreCell() } } .listStyle(.plain) .overlay { if quotes.isEmpty { - QuotesEmptyView() + FeedEmptyView { + ContentUnavailableView("Nothing here yet", systemImage: "quote.bubble") + } } } .refreshable { - await refreshCatalogue() - await refreshBlog() + await fetch(page: nil) + } + // Runs on appear and restarts on any term or filter change (the id is the page-1 + // query) — the model supersedes in-flight requests, so the last input wins. + .task(id: query()) { + await fetch(page: nil) } .sheet(isPresented: $showRandom) { RandomQuoteSheet(quotes: quotes) } } + + /// Fetches for the given paging state — `nil` is page 1 — and folds the receipt back in. + /// The `?? page` fallback is the failure policy: a failed page 1 falls back to `nil` + /// (no dead sessions), while a failed next page falls back to its own pre-call state, + /// so a later row appearance retries it. + private func fetch(page: FeedPage?) async { + self.page = await feed.fetch(query(page: page)) ?? page + } + + /// Row-driven paging: a row at or past the fetched frontier is unverified local cache — + /// fetch the next page for it. The `nextCursor` check gates exhaustion: without it, an + /// exhausted page would build a cursor-less query and restart the session as page 1. + private func fetch(after createdAt: Date) async { + guard let page, page.nextCursor != nil, + let frontier = page.frontier, + createdAt <= frontier else { return } + await fetch(page: page) + } + + private func query(page: FeedPage? = nil) -> QuotesQuery { + QuotesQuery( + cursor: page?.nextCursor, + languages: languages, + sources: sources.apiValues, + term: searchText + ) + } } diff --git a/app-core/Sources/AppCore/Quotes/View+Quotes.swift b/app-core/Sources/AppCore/Quotes/View+Quotes.swift index b18cc14b..38b60b32 100644 --- a/app-core/Sources/AppCore/Quotes/View+Quotes.swift +++ b/app-core/Sources/AppCore/Quotes/View+Quotes.swift @@ -1,12 +1,11 @@ import SwiftUI public extension View { - /// Injects the Quotes model + its source-selection action for the tab subtree. + /// Injects the Quotes filter model + its source-selection action for the tab subtree. /// - /// Call this once at the composition root alongside `.blog(_:)` and `.catalogue(_:)`: + /// Call this once at the composition root alongside `.catalogue(_:)`: /// /// ContentView() - /// .blog(blog) /// .catalogue(catalogue) /// .quotes(quotes) func quotes(_ model: QuotesModel) -> some View { diff --git a/app-core/Sources/AppCore/Search/SearchTab.swift b/app-core/Sources/AppCore/Search/SearchTab.swift index 04db1e45..d04b69bf 100644 --- a/app-core/Sources/AppCore/Search/SearchTab.swift +++ b/app-core/Sources/AppCore/Search/SearchTab.swift @@ -1,25 +1,230 @@ import SwiftUI +import SwiftData +import AppPersistence +import TmbrCore + +/// Scope for the dedicated Search tab's segmented scope bar. +enum SearchScope: String, CaseIterable { + case all = "All" + case blog = "Blog" + case catalogue = "Catalogue" + case quotes = "Quotes" +} public struct SearchTab: View { + + @Preferences(\.selectedLanguages) + private var selectedLanguages + + @Catalogue(\.selectedCategories) + private var selectedCategories + + @Quotes(\.selectedSources) + private var selectedSources + @State private var searchText = "" + @State private var selectedScope: SearchScope = .all public init() {} public var body: some View { NavigationStack { - Group { - if searchText.isEmpty { - ContentUnavailableView( - "Search", - systemImage: "magnifyingglass", - description: Text("Posts, songs, books, movies, and more.") - ) - } else { - List(placeholderResults(for: searchText)) { result in + SearchContent( + term: searchText.isEmpty ? nil : searchText, + scope: selectedScope, + selectedLanguages: selectedLanguages, + selectedCategories: selectedCategories, + selectedSources: selectedSources + ) + .feed(.posts) + .feed(.quotes) + .feed(.catalogueSearch) + .navigationTitle("Search") + .searchable(text: $searchText, prompt: "Posts, songs, books…") + .searchScopes($selectedScope, activation: .onSearchPresentation) { + ForEach(SearchScope.allCases, id: \.self) { scope in + Text(scope.rawValue).tag(scope) + } + } + .navigationDestination(for: PostRecord.self) { post in + PostReaderView( + title: post.title, + content: post.content, + created: post.createdAt, + published: post.publishedAt + ) + } + .navigationDestination(for: CatalogueItemNavigation.self) { CatalogueItemNavigation.destination($0) } + } + } +} + +// MARK: - Content (owns the feeds; SearchTab injects them above) + +private struct SearchContent: View { + + @Feed(PostsQuery.self) + private var posts + + @Feed(QuotesQuery.self) + private var quotes + + @Feed(CatalogueSearchQuery.self) + private var catalogueSearch + + @Environment(\.modelContext) + private var modelContext + + @State private var catalogueEngine = CatalogueSearchEngine() + + let term: String? + let scope: SearchScope + let selectedLanguages: Set + let selectedCategories: Set + let selectedSources: Set + + /// The three feeds' typed inputs for the current term + filters — bundled so one value can + /// key the `.task` below; `nil` when not searching. View-local, not a domain type. + private struct SearchQueries: Hashable { + let posts: PostsQuery + let quotes: QuotesQuery + let catalogue: CatalogueSearchQuery + } + + private var queries: SearchQueries? { + guard let term else { return nil } + let languages = selectedLanguages.apiValues + return SearchQueries( + posts: PostsQuery(languages: selectedLanguages, term: term), + quotes: QuotesQuery(languages: selectedLanguages, sources: selectedSources.apiValues, term: term), + catalogue: CatalogueSearchQuery(term: term, types: selectedCategories.apiValues, languages: languages) + ) + } + + var body: some View { + Group { + if let term { + SearchResultsView( + term: term, + scope: scope, + catalogueEngine: catalogueEngine, + selectedLanguages: selectedLanguages, + selectedCategories: selectedCategories, + selectedSources: selectedSources + ) + } else { + ContentUnavailableView( + "Search", + systemImage: "magnifyingglass", + description: Text("Posts, songs, books, movies, and more.") + ) + } + } + // Fire all network searches in parallel when the term or a filter changes — each + // model supersedes its own in-flight request, so the last input wins per feed. + .task(id: queries) { + guard let queries else { + catalogueEngine.clear() + return + } + let term = queries.catalogue.term + // Local catalogue search runs immediately. + catalogueEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + // All three network searches fire concurrently, each with its own typed input. + await withTaskGroup(of: Void.self) { group in + group.addTask { [posts] in await posts.fetch(queries.posts) } + group.addTask { [catalogueSearch] in await catalogueSearch.fetch(queries.catalogue) } + group.addTask { [quotes] in await quotes.fetch(queries.quotes) } + } + guard !Task.isCancelled else { return } + // Re-run local catalogue search to pick up newly upserted items/notes. + catalogueEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + } + } +} + +// MARK: - Results view (switches on scope) + +private struct SearchResultsView: View { + + let term: String + let scope: SearchScope + let catalogueEngine: CatalogueSearchEngine + let selectedLanguages: Set + let selectedCategories: Set + let selectedSources: Set + + var body: some View { + List { + switch scope { + case .all: + PostsSection(term: term, selectedLanguages: selectedLanguages) + CatalogueSection(engine: catalogueEngine) + QuotesSection(term: term, selectedSources: selectedSources, selectedLanguages: selectedLanguages) + case .blog: + PostsSection(term: term, selectedLanguages: selectedLanguages) + case .catalogue: + CatalogueSection(engine: catalogueEngine) + case .quotes: + QuotesSection(term: term, selectedSources: selectedSources, selectedLanguages: selectedLanguages) + } + } + .listStyle(.plain) + } +} + +// MARK: - Blog section + +private struct PostsSection: View { + + @Query private var posts: [PostRecord] + + init(term: String, selectedLanguages: Set) { + _posts = Query( + filter: PostRecord.search(term: term, languages: selectedLanguages), + sort: \PostRecord.createdAt, + order: .reverse + ) + } + + var body: some View { + if !posts.isEmpty { + Section("Blog") { + ForEach(posts) { post in + NavigationLink(value: post) { + PostCell( + title: post.title, + date: post.publishedAt ?? post.createdAt + ) + } + } + } + } + } +} + +// MARK: - Catalogue section + +private struct CatalogueSection: View { + + let engine: CatalogueSearchEngine + + var body: some View { + if !engine.results.isEmpty { + Section("Catalogue") { + ForEach(engine.results) { result in + NavigationLink { + CatalogueItemDetailView(item: result.preview) + } label: { VStack(alignment: .leading, spacing: 2) { - Text(result.primaryInfo) - if let secondary = result.secondaryInfo { - Text(secondary) + 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) } @@ -27,23 +232,31 @@ public struct SearchTab: View { } } } - .navigationTitle("Search") - .searchable(text: $searchText, prompt: "Posts, songs, books…") } } +} + +// MARK: - Quotes section + +private struct QuotesSection: View { - private struct SearchResult: Identifiable { - let id = UUID() - let primaryInfo: String - let secondaryInfo: String? + @Query private var quotes: [QuoteRecord] + + init(term: String, selectedSources: Set, selectedLanguages: Set) { + _quotes = Query( + filter: QuoteRecord.search(term: term, sources: selectedSources, languages: selectedLanguages), + sort: \QuoteRecord.createdAt, + order: .reverse + ) } - private func placeholderResults(for query: String) -> [SearchResult] { - [ - .init(primaryInfo: "The Glow Pt. 2", secondaryInfo: "The Microphones · Album"), - .init(primaryInfo: "On keeping a reading journal", secondaryInfo: "Post"), - .init(primaryInfo: "Stranger in the Alps", secondaryInfo: "Phoebe Bridgers · Album"), - .init(primaryInfo: "Normal People", secondaryInfo: "Sally Rooney · Book"), - ] + var body: some View { + if !quotes.isEmpty { + Section("Quotes") { + ForEach(quotes) { quote in + QuoteCell(quote: quote) + } + } + } } } diff --git a/app-core/Tests/CoreAppTests/BlogModelTests.swift b/app-core/Tests/CoreAppTests/BlogModelTests.swift deleted file mode 100644 index 16470c9e..00000000 --- a/app-core/Tests/CoreAppTests/BlogModelTests.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Testing -import Foundation -@testable import AppCore - -@MainActor -@Suite("BlogModel") -struct BlogModelTests { - - // MARK: - Helpers - - private func makeModel( - refresh: @escaping @Sendable () async throws -> Date? = { nil }, - loadMore: @escaping @Sendable () async throws -> Bool = { false }, - lastFetched: Date? = nil - ) -> BlogModel { - BlogModel(refresh: refresh, loadMore: loadMore, lastFetched: lastFetched) - } - - // MARK: - Initial state - - @Test func initialActiveLoadIsNil() { - #expect(makeModel().loading == nil) - } - - @Test func initialLastFetchedIsNil() { - #expect(makeModel().lastFetched == nil) - } - - @Test func initialLastFetchedUsesInjectedValue() { - let date = Date(timeIntervalSince1970: 1_000_000) - #expect(makeModel(lastFetched: date).lastFetched == date) - } - - // MARK: - refresh() success - - @Test func refreshSuccessSetsLastFetchedFromClosure() async { - let expected = Date(timeIntervalSince1970: 2_000_000) - let model = makeModel(refresh: { expected }) - await model.refresh() - #expect(model.lastFetched == expected) - } - - @Test func refreshReturningNilDoesNotUpdateLastFetched() async { - let prior = Date(timeIntervalSince1970: 1_000_000) - let model = makeModel(refresh: { nil }, lastFetched: prior) - await model.refresh() - #expect(model.lastFetched == prior) - } - - @Test func refreshSuccessClearsLastError() async { - // Start in failed state - let failing = makeModel(refresh: { throw LoadError.offline }) - await failing.refresh() - guard failing.lastError != nil else { - Issue.record("Expected lastError to be set after failed refresh") - return - } - // Succeed — error must be cleared - let model = makeModel(refresh: { Date.now }) - await model.refresh() - #expect(model.lastError == nil) - } - - @Test func refreshSuccessResetsHasMoreOptimistically() async { - let model = makeModel(loadMore: { false }) - await model.refresh() - await model.loadMore() // settles hasMore = false - #expect(!model.hasMore) - await model.refresh() - #expect(model.hasMore) - } - - @Test func refreshActiveLoadIsNilAfterCompletion() async { - let model = makeModel(refresh: { Date.now }) - await model.refresh() - #expect(model.loading == nil) - } - - // MARK: - refresh() failure - - @Test func refreshFailureSetsLastError() async { - let model = makeModel(refresh: { throw LoadError.offline }) - await model.refresh() - #expect(model.lastError == .offline) - } - - @Test func refreshFailurePreservesLastFetched() async { - let prior = Date(timeIntervalSince1970: 1_000_000) - let model = makeModel( - refresh: { throw LoadError.offline }, - lastFetched: prior - ) - await model.refresh() - #expect(model.lastFetched == prior) - } - - @Test func refreshFailureActiveLoadIsNilAfterCompletion() async { - let model = makeModel(refresh: { throw LoadError.offline }) - await model.refresh() - #expect(model.loading == nil) - } - - // MARK: - loadMore() happy path - - @Test func loadMoreSetsHasMoreFalseWhenNoCursorRemains() async { - let model = makeModel(loadMore: { false }) - await model.refresh() - #expect(model.hasMore) - await model.loadMore() - #expect(!model.hasMore) - } - - @Test func loadMoreKeepsHasMoreTrueWhenCursorRemains() async { - let model = makeModel(loadMore: { true }) - await model.refresh() - await model.loadMore() - #expect(model.hasMore) - } - - @Test func isPageLoadingFalseAfterLoadMoreCompletes() async { - let model = makeModel(loadMore: { false }) - await model.refresh() - await model.loadMore() - #expect(model.loading != .page) - } - - // MARK: - loadMore() guards - - @Test func loadMoreBlockedWhenHasMoreIsFalse() async { - var callCount = 0 - let model = makeModel(loadMore: { @MainActor in - callCount += 1 - return false - }) - await model.refresh() - await model.loadMore() // runs, sets hasMore = false - await model.loadMore() // blocked — hasMore == false - #expect(callCount == 1) - } - - @Test func loadMoreErrorIsSwallowedAndDoesNotSetLastError() async { - let model = makeModel( - refresh: { Date.now }, - loadMore: { throw LoadError.offline } - ) - await model.refresh() - await model.loadMore() - #expect(model.lastError == nil) - } -} diff --git a/app-persistence/Sources/AppPersistence/QuoteRecord.swift b/app-persistence/Sources/AppPersistence/QuoteRecord.swift index 295659fc..32ed9819 100644 --- a/app-persistence/Sources/AppPersistence/QuoteRecord.swift +++ b/app-persistence/Sources/AppPersistence/QuoteRecord.swift @@ -107,6 +107,18 @@ public extension QuoteRecord { } } + /// Predicate for the quotes list — text search when `term` is non-empty, plain list + /// otherwise; source and language filters always apply. Mirrors what the server returns + /// for the same inputs, so one set of inputs drives both the local `@Query` and the + /// network fetch. + static func quotes(term: String, sources: Set, languages: Set) -> Predicate { + if !term.isEmpty { + search(term: term, sources: sources, languages: languages) + } else { + list(sources: sources, languages: languages) + } + } + /// Predicate for in-tab text search. Matches quotes whose body contains `term` /// (case-insensitive, locale-aware), filtered by active source and language selections. static func search(term: String, sources: Set, languages: Set) -> Predicate {