Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ After changing code: `make format`, then `make build`. Simulator interaction and
- User-facing metadata lists (authors, publishers, genres, tags, languages) sort with `Collection.localizedSorted()`; authors via `Author.sortedByRole()`. Never revert to raw `.sorted()`. (`MetadataIndex` encode keys and SQL clause ordering intentionally keep plain `.sorted()`.)
- Online ordering is server-side; the app-local pinned flag is invisible to the server, so online pages prepend pinned items and filter them out of the server stream.
- Dashboard progress sections always revalidate on `.readingProgress`; other sections skip unless browse options are progress-sensitive (`isSensitiveToReadingProgress`).
- Dashboard rows load through view models the row views own (`DashboardSectionViewModel`, `DashboardPinnedSectionViewModel`), never in a view `.task`: switching the split-view sidebar back to Home adds, removes, and re-adds the dashboard within milliseconds with its state kept, which cancels view-scoped loads mid-request. Appearing starts a load only when none has completed or is running (pinned rows refresh on every appear but join a running refresh); a reload supersedes a load in flight (newest wins) instead of being dropped. Do not reintroduce load-once flags or `isLoading` guards that drop a re-run.
- Dashboard rows load through view models the row views own (`DashboardSectionViewModel`, `DashboardPinnedSectionViewModel`), never in a view `.task`: switching the split-view sidebar back to Home adds, removes, and re-adds the dashboard within milliseconds with its state kept, which cancels view-scoped loads mid-request. Appearing starts a load only when none has completed or is running (pinned rows refresh on every appear but join a running refresh for the same server); a reload supersedes a load in flight (newest wins) instead of being dropped. Do not reintroduce load-once flags or `isLoading` guards that drop a re-run.
- `.refreshable` closures must await the reload they trigger, so the refresh control dismisses onto settled content instead of racing in-flight view updates. Dashboard manual refreshes suspend in `DashboardRefreshCoordinator` until every rendered section acknowledges the command (section views register on appear/disappear); `OfflineView` awaits the browse view models it owns and shares with `OfflineSeriesBrowseView`/`OfflineBooksBrowseView`, whose `refreshBrowse()` re-runs the view model's current query; library-selection and account-switch reloads instead bump a `refreshTrigger` passed to those child views, so the reload runs through the child and captures the current `libraryIds` rather than replaying the view model's stale query.
- Dashboard section offline downloads live only on the section detail page (`DashboardSectionDetailView`), as a single download menu (toolbar on iOS/macOS, inline on tvOS) shown for `supportsDownloadLatest` sections: every such section offers queueing the latest 20 books, and Keep Reading / On Deck (`supportsDownloadAll`) additionally offer queueing the whole section page by page. The Dashboard home toolbar carries no download actions.
- Card sizes are fixed per platform, calibrated against Apple Books (`LayoutConfig`); there is no user-adjustable grid density. Dashboard sections render one of three card kinds: `large` (fresh content showcase: on deck, recently released/added books, recently updated series), `small` (library activity/history: recently added series, recently read books), `horizontal` (Keep Reading books, pinned read lists/collections). `DashboardSection.cardKind` is only the default — the user can override any books/series section from the menu at the trailing edge of its header row (books sections offer large/small/horizontal, series sections large/small; pinned sections stay horizontal-only), persisted in `DashboardConfiguration.cardKindOverrides`. Small cards are cover-only (`coverOnly`): every text line truncates at that width and stops carrying information. On horizontal cards the series/title block is top-aligned and the bottom bar (progress, download icon) is pinned to the card's bottom as an overlay, so the icon sits in the bottom-right corner instead of floating mid-card.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ final class DashboardPinnedSectionViewModel {

/// Refreshes when the row appears, joining a refresh already running for
/// the same server.
func refreshIfIdle(instanceId: String) {
func refreshIfNeeded(instanceId: String) {
guard refreshTask == nil || refreshingInstanceId != instanceId else { return }
startRefresh(instanceId: instanceId)
}
Expand Down
24 changes: 8 additions & 16 deletions KMReader/Features/Dashboard/Views/DashboardPinnedSectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,6 @@ struct DashboardPinnedSectionView: View {
_viewModel = State(initialValue: DashboardPinnedSectionViewModel(section: section))
}

private var pinnedCollections: [CollectionDisplayItem] {
viewModel.pinnedCollections
}

private var pinnedReadLists: [ReadListDisplayItem] {
viewModel.pinnedReadLists
}

private var isSupportedSection: Bool {
switch section.contentKind {
case .collections, .readLists:
Expand All @@ -47,9 +39,9 @@ struct DashboardPinnedSectionView: View {
private var hasItems: Bool {
switch section.contentKind {
case .collections:
return !pinnedCollections.isEmpty
return !viewModel.pinnedCollections.isEmpty
case .readLists:
return !pinnedReadLists.isEmpty
return !viewModel.pinnedReadLists.isEmpty
default:
return false
}
Expand All @@ -58,9 +50,9 @@ struct DashboardPinnedSectionView: View {
private var itemIds: [String] {
switch section.contentKind {
case .collections:
return pinnedCollections.map(\.collectionId)
return viewModel.pinnedCollections.map(\.collectionId)
case .readLists:
return pinnedReadLists.map(\.readListId)
return viewModel.pinnedReadLists.map(\.readListId)
default:
return []
}
Expand Down Expand Up @@ -140,7 +132,7 @@ struct DashboardPinnedSectionView: View {
LazyHStack(alignment: .top, spacing: spacing) {
switch section.contentKind {
case .collections:
ForEach(pinnedCollections) { collection in
ForEach(viewModel.pinnedCollections) { collection in
CollectionHorizontalCardView(
item: collection,
coverWidth: horizontalCoverWidth,
Expand All @@ -154,7 +146,7 @@ struct DashboardPinnedSectionView: View {
.frame(width: horizontalCardWidth)
}
case .readLists:
ForEach(pinnedReadLists) { readList in
ForEach(viewModel.pinnedReadLists) { readList in
ReadListHorizontalCardView(
item: readList,
coverWidth: horizontalCoverWidth,
Expand Down Expand Up @@ -228,13 +220,13 @@ struct DashboardPinnedSectionView: View {
}
.onAppear {
DashboardRefreshCoordinator.shared.registerSection(section)
viewModel.refreshIfIdle(instanceId: currentInstanceId)
viewModel.refreshIfNeeded(instanceId: currentInstanceId)
}
.onDisappear {
DashboardRefreshCoordinator.shared.unregisterSection(section)
}
.onChange(of: currentInstanceId) { _, instanceId in
viewModel.refreshIfIdle(instanceId: instanceId)
viewModel.refreshIfNeeded(instanceId: instanceId)
}
}
}
Expand Down
18 changes: 7 additions & 11 deletions KMReader/Features/Dashboard/Views/DashboardSectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ struct DashboardSectionView: View {
_viewModel = State(initialValue: DashboardSectionViewModel(section: section))
}

private var pagination: PaginationState<IdentifiedString> {
viewModel.pagination
}

private var backgroundColors: [Color] {
if colorScheme == .dark {
return [
Expand Down Expand Up @@ -97,7 +93,7 @@ struct DashboardSectionView: View {
}
}
.buttonStyle(.plain)
.disabled(pagination.isEmpty)
.disabled(viewModel.pagination.isEmpty)

Spacer()

Expand Down Expand Up @@ -127,7 +123,7 @@ struct DashboardSectionView: View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: spacing) {
ForEach(pagination.items) { item in
ForEach(viewModel.pagination.items) { item in
itemView(for: item.id)
.id(item.id)
.frame(width: itemWidth)
Expand All @@ -146,14 +142,14 @@ struct DashboardSectionView: View {
#if os(macOS)
.macHorizontalScrollButtons(
scrollProxy: proxy,
itemIds: pagination.items.map(\.id)
itemIds: viewModel.pagination.items.map(\.id)
)
#endif
}
}
}
.opacity(pagination.isEmpty ? 0 : 1)
.frame(height: pagination.isEmpty ? 0 : nil)
.opacity(viewModel.pagination.isEmpty ? 0 : 1)
.frame(height: viewModel.pagination.isEmpty ? 0 : nil)
.onReceive(NotificationCenter.default.publisher(for: .dashboardSectionsShouldReload)) {
notification in
guard let command = DashboardSectionRefreshNotifier.reloadCommand(from: notification) else {
Expand Down Expand Up @@ -204,9 +200,9 @@ struct DashboardSectionView: View {
return
}

if command.source == .auto, pagination.currentPage > 1 {
if command.source == .auto, viewModel.pagination.currentPage > 1 {
logger.debug(
"Dashboard section \(section) skipping auto-refresh: deep in pagination (page \(pagination.currentPage))"
"Dashboard section \(section) skipping auto-refresh: deep in pagination (page \(viewModel.pagination.currentPage))"
)
return
}
Expand Down