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
11 changes: 9 additions & 2 deletions app-api/Sources/AppApi/HTTP/RequestLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,21 @@ public extension RequestLoader where Input == Void {
public extension RequestLoader {

/// Loads `request` without authentication.
init<R: Request>(request: R, session: URLSession = .shared) where R.Input == Input, R.Response == Response {
init<R: Request>(
request: R,
session: URLSession = .shared
) where R.Input == Input, R.Response == Response {
self.init { input in
try await Self.send(request, input, token: nil, session: session)
}
}

/// Loads `request` with a bearer token from `auth`, refreshing once on a 401 and retrying.
init<R: Request>(request: R, session: URLSession = .shared, auth: AuthProvider) where R.Input == Input, R.Response == Response {
init<R: Request>(
request: R,
session: URLSession = .shared,
auth: AuthProvider
) where R.Input == Input, R.Response == Response {
self.init { input in
let token = await auth.value
do {
Expand Down
14 changes: 7 additions & 7 deletions app-api/Sources/AppApi/Sync/SyncLoaders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public typealias BooksLoader = RequestLoader<PageQuery, PageResult<BookRespo
public typealias MoviesLoader = RequestLoader<PageQuery, PageResult<MovieResponse>>
public typealias PodcastsLoader = RequestLoader<PageQuery, PageResult<PodcastResponse>>
public typealias PlaylistsLoader = RequestLoader<PageQuery, PageResult<PlaylistResponse>>
public typealias PostsLoader = RequestLoader<PageQuery, PageResult<PostResponse>>
public typealias PostsLoader = RequestLoader<PostsQuery, PageResult<PostResponse>>
public typealias OrphansLoader = RequestLoader<OrphanPageQuery, PageResult<PreviewResponse>>
public typealias CategoriesLoader = RequestLoader<Void, [CategoryResponse]>
public typealias DeletionsLoader = RequestLoader<SinceQuery, [DeletionRecord]>
Expand Down Expand Up @@ -138,15 +138,15 @@ public extension DeletionsLoader {

// MARK: - Search loaders (unauthenticated — Reader app)
//
// One-shot loaders: call `.load(from: query)` with the search query, get results back.
// No pagination — search endpoints return an unpaginated array.
// Catalogue uses a dedicated search endpoint. Quotes reuses its paginated list endpoint with
// an optional `term` field — same pattern as PostsLoader.

public typealias PostSearchLoader = RequestLoader<PostSearchQuery, PageResult<PostResponse>>
public typealias QuotesLoader = RequestLoader<QuotesQuery, PageResult<QuoteResponse>>
public typealias CatalogueSearchLoader = RequestLoader<CatalogueSearchQuery, CatalogueSearchResponse>

public extension PostSearchLoader {
static func searchPosts(baseURL: URL, session: URLSession = .shared) -> Self {
PostSearchLoader(request: PostSearchRequest.postSearch(baseURL: baseURL), session: session)
public extension QuotesLoader {
static func quotes(baseURL: URL, session: URLSession = .shared) -> Self {
QuotesLoader(request: QuotesRequest.quotes(baseURL: baseURL), session: session)
}
}

Expand Down
14 changes: 7 additions & 7 deletions app-api/Sources/AppApi/Sync/SyncRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public extension Request where Self == PlaylistsRequest {
static func playlistQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/playlists") }
}

public typealias PostsRequest = BasicRequest<PageQuery, PageResult<PostResponse>>
public typealias PostsRequest = BasicRequest<PostsQuery, PageResult<PostResponse>>
public extension Request where Self == PostsRequest {
static func postQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/posts") }
}
Expand All @@ -62,13 +62,13 @@ public extension Request where Self == DeletionsRequest {

// MARK: - Search requests
//
// Unpaginated one-shot requests — send the search query, get a complete result array back.
// Each uses `.query(baseURL:path:)` so the `Input` struct is URL-encoded into query parameters
// via `QueryItemEncoder`.
// Catalogue search uses a dedicated endpoint; quotes and blog reuse their paginated list endpoints
// with an optional `term` field. Each uses `.query(baseURL:path:)` so the Input struct is
// URL-encoded into query parameters via `QueryItemEncoder`.

public typealias PostSearchRequest = BasicRequest<PostSearchQuery, PageResult<PostResponse>>
public extension Request where Self == PostSearchRequest {
static func postSearch(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/posts") }
public typealias QuotesRequest = BasicRequest<QuotesQuery, PageResult<QuoteResponse>>
public extension Request where Self == QuotesRequest {
static func quotes(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/quotes") }
}

public typealias CatalogueSearchRequest = BasicRequest<CatalogueSearchQuery, CatalogueSearchResponse>
Expand Down
7 changes: 6 additions & 1 deletion app-core/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ let package = Package(
),
.testTarget(
name: "AppCoreTests",
dependencies: ["AppCore"]
dependencies: [
"AppCore",
.product(name: "TmbrCore", package: "tmbr-core"),
.product(name: "AppApi", package: "app-api"),
.product(name: "AppPersistence", package: "app-persistence"),
]
),
]
)
6 changes: 1 addition & 5 deletions app-core/Sources/AppCore/Blog/BlogModel.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import Foundation
import OSLog

/// Which load operation is currently in flight, if any.
public enum LoadingState: Equatable, Sendable {
case refresh
case page
}
// `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.
Expand Down
12 changes: 6 additions & 6 deletions app-core/Sources/AppCore/Catalogue/CatalogueItemDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,17 @@ struct CatalogueItemDetailView: View {
private var typeSection: some View {
switch item.category {
case .song:
SongDetailSection(previewID: item.id)
SongDetailSection(item: item)
case .album:
AlbumDetailSection(previewID: item.id)
AlbumDetailSection(item: item)
case .playlist:
PlaylistDetailSection(previewID: item.id)
PlaylistDetailSection(item: item)
case .book:
BookDetailSection(previewID: item.id)
BookDetailSection(item: item)
case .podcast:
PodcastDetailSection(previewID: item.id)
PodcastDetailSection(item: item)
case .movie:
MovieDetailSection(previewID: item.id)
MovieDetailSection(item: item)
case nil:
OrphanDetailSection(item: item)
}
Expand Down
4 changes: 2 additions & 2 deletions app-core/Sources/AppCore/Catalogue/CatalogueTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ private struct CatalogueItemsList: View {
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(item.primaryInfo)
if let subtitle = item.secondaryInfo {
Text(subtitle)
if !item.secondaryInfo.isEmpty {
Text(item.secondaryInfo)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Expand Down
39 changes: 24 additions & 15 deletions app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,44 @@ private struct AlbumInfoLine: View {

struct AlbumDetailSection: View {

let previewID: UUID
let item: PreviewRecord

@Query private var records: [AlbumRecord]
@Query
private var records: [AlbumRecord]

@Upserter(\.album) private var syncer
@Upserter(.album)
private var syncer

init(previewID: UUID) {
self.previewID = previewID
init(item: PreviewRecord) {
self.item = item
let previewID = item.id
_records = Query(filter: #Predicate<AlbumRecord> { $0.previewID == previewID })
}

var body: some View {
if let album = records.first {
let album = records.first
let artist = album?.artist ?? item.secondaryInfo
Group {
Section {
CatalogueItemHeader(
title: album.title,
artworkURL: album.artworkURL,
credit: album.artist.isEmpty ? nil : "by \(album.artist)",
info: { AlbumInfoLine(album: album) },
resourceURLs: album.resourceURLs
title: album?.title ?? item.primaryInfo,
artworkURL: album?.artworkURL ?? item.imageURL,
credit: artist.isEmpty ? nil : "by \(artist)",
info: { if let album { AlbumInfoLine(album: album) } },
resourceURLs: album?.resourceURLs ?? item.externalLinks
)
}
.catalogueItemRefresh(id: previewID) { [sourceID = album.sourceID] in
if let sourceID { try await syncer(sourceID) }
}
if let sourceID = album.sourceID {
if let sourceID = item.sourceID {
TrackListSection(containerType: "album", containerSourceID: sourceID)
}
}
.catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in
if let sourceID { try await syncer(sourceID) }
}
.task(id: item.id) {
guard records.isEmpty, let sourceID = item.sourceID else { return }
try? await syncer(sourceID)
}
}

}
43 changes: 25 additions & 18 deletions app-core/Sources/AppCore/Catalogue/Detail/BookDetailSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,38 @@ private struct BookInfoLine: View {

struct BookDetailSection: View {

let previewID: UUID
let item: PreviewRecord

@Query private var records: [BookRecord]
@Query
private var records: [BookRecord]

@Upserter(\.book) private var syncer
@Upserter(.book)
private var syncer

init(previewID: UUID) {
self.previewID = previewID
init(item: PreviewRecord) {
self.item = item
let previewID = item.id
_records = Query(filter: #Predicate<BookRecord> { $0.previewID == previewID })
}

var body: some View {
if let book = records.first {
Section {
CatalogueItemHeader(
title: book.title,
artworkURL: book.coverURL,
credit: book.author.isEmpty ? nil : "by \(book.author)",
info: { BookInfoLine(book: book) },
resourceURLs: book.resourceURLs
)
}
.catalogueItemRefresh(id: previewID) { [sourceID = book.sourceID] in
if let sourceID { try await syncer(sourceID) }
}
let book = records.first
let author = book?.author ?? item.secondaryInfo
Section {
CatalogueItemHeader(
title: book?.title ?? item.primaryInfo,
artworkURL: book?.coverURL ?? item.imageURL,
credit: author.isEmpty ? nil : "by \(author)",
info: { if let book { BookInfoLine(book: book) } },
resourceURLs: book?.resourceURLs ?? item.externalLinks
)
}
.catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in
if let sourceID { try await syncer(sourceID) }
}
.task(id: item.id) {
guard records.isEmpty, let sourceID = item.sourceID else { return }
try? await syncer(sourceID)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import SwiftUI
import AppPersistence

/// A preview-backed catalogue item header, rendered entirely from a `PreviewRecord`.
/// Used as a placeholder by typed detail sections while their backing record is being
/// fetched, and as the permanent header by `OrphanDetailSection`.
struct CataloguePreviewHeader: View {

let item: PreviewRecord

var body: some View {
Section {
CatalogueItemHeader(
title: item.primaryInfo,
artworkURL: item.imageURL,
info: { PreviewInfoLine(item: item) },
resourceURLs: item.externalLinks
)
}
}
}

// MARK: - Info line

struct PreviewInfoLine: View {

let item: PreviewRecord

var body: some View {
let text = [item.secondaryInfo, item.categoryType.capitalized]
.filter { !$0.isEmpty }.joined(separator: " · ")
if !text.isEmpty {
Text(text).foregroundStyle(.secondary)
}
}
}
36 changes: 20 additions & 16 deletions app-core/Sources/AppCore/Catalogue/Detail/MovieDetailSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,34 @@ private struct MovieInfoLine: View {

struct MovieDetailSection: View {

let previewID: UUID
let item: PreviewRecord

@Query private var records: [MovieRecord]

@Upserter(\.movie) private var syncer
@Upserter(.movie) private var syncer

init(previewID: UUID) {
self.previewID = previewID
init(item: PreviewRecord) {
self.item = item
let previewID = item.id
_records = Query(filter: #Predicate<MovieRecord> { $0.previewID == previewID })
}

var body: some View {
if let movie = records.first {
Section {
CatalogueItemHeader(
title: movie.title,
artworkURL: movie.coverURL,
info: { MovieInfoLine(movie: movie) },
resourceURLs: movie.resourceURLs
)
}
.catalogueItemRefresh(id: previewID) { [sourceID = movie.sourceID] in
if let sourceID { try await syncer(sourceID) }
}
let movie = records.first
Section {
CatalogueItemHeader(
title: movie?.title ?? item.primaryInfo,
artworkURL: movie?.coverURL ?? item.imageURL,
info: { if let movie { MovieInfoLine(movie: movie) } },
resourceURLs: movie?.resourceURLs ?? item.externalLinks
)
}
.catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in
if let sourceID { try await syncer(sourceID) }
}
.task(id: item.id) {
guard records.isEmpty, let sourceID = item.sourceID else { return }
try? await syncer(sourceID)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,16 @@
import SwiftUI
import AppPersistence

private struct OrphanInfoLine: View {
let item: PreviewRecord

var body: some View {
let text = [item.secondaryInfo, item.categoryType.capitalized]
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
if !text.isEmpty {
Text(text).foregroundStyle(.secondary)
}
}
}

struct OrphanDetailSection: View {

let item: PreviewRecord

@Upserter(\.orphan) private var syncer
@Upserter(.orphan) private var syncer

var body: some View {
Section {
CatalogueItemHeader(
title: item.primaryInfo,
artworkURL: item.imageURL,
info: { OrphanInfoLine(item: item) },
resourceURLs: item.externalLinks
)
}
.catalogueItemRefresh(id: item.id) { [id = item.id] in
try await syncer(id)
}
CataloguePreviewHeader(item: item)
.catalogueItemRefresh(id: item.id) { [id = item.id] in
try await syncer(id)
}
}
}
Loading