All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
147 lines
4.4 KiB
Swift
147 lines
4.4 KiB
Swift
import Foundation
|
|
|
|
// MARK: - BrowseViewModel
|
|
// Powers both the Discover shelves (BrowseView) and the full paginated grid (BrowseCategoryView).
|
|
// Uses @Observable (iOS 17+).
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class BrowseViewModel {
|
|
|
|
// MARK: - Discover shelves (BrowseView)
|
|
|
|
var trending: [BrowseNovel] = []
|
|
var newReleases: [BrowseNovel] = []
|
|
var recentlyUpdated: [BrowseNovel] = []
|
|
var ranking: [BrowseNovel] = []
|
|
|
|
// MARK: - Paginated grid (BrowseCategoryView)
|
|
|
|
var novels: [BrowseNovel] = []
|
|
var currentPage = 1
|
|
var hasNext = false
|
|
|
|
// Filter params (BrowseCategoryView sets these before calling loadFirstPage)
|
|
var sort: String = "popular"
|
|
var genre: String = "all"
|
|
var status: String = "all"
|
|
|
|
// MARK: - UI state
|
|
|
|
var isLoading = false
|
|
var isLoadingMore = false
|
|
var error: String?
|
|
|
|
// MARK: - Discover load (fetches multiple shelves in parallel)
|
|
|
|
func loadShelves() async {
|
|
isLoading = true
|
|
error = nil
|
|
|
|
do {
|
|
async let trendingTask = APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
|
|
async let newTask = APIClient.shared.browse(page: 1, genre: "all", sort: "new", status: "all")
|
|
async let updatedTask = APIClient.shared.browse(page: 1, genre: "all", sort: "update", status: "all")
|
|
async let rankingTask = APIClient.shared.ranking()
|
|
|
|
let (trendingResp, newResp, updatedResp, rankItems) = try await (
|
|
trendingTask, newTask, updatedTask, rankingTask
|
|
)
|
|
|
|
trending = Array(trendingResp.novels.prefix(12))
|
|
newReleases = Array(newResp.novels.prefix(12))
|
|
recentlyUpdated = Array(updatedResp.novels.prefix(12))
|
|
ranking = rankItems.prefix(12).map { item in
|
|
BrowseNovelFromRanking(item)
|
|
}
|
|
} catch {
|
|
if !(error is CancellationError) {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
// MARK: - Paginated category load
|
|
|
|
func loadFirstPage() async {
|
|
guard !isLoading else { return }
|
|
novels = []
|
|
currentPage = 1
|
|
hasNext = false
|
|
isLoading = true
|
|
error = nil
|
|
|
|
do {
|
|
let resp = try await APIClient.shared.browse(
|
|
page: 1, genre: genre, sort: sort, status: status)
|
|
novels = resp.novels
|
|
currentPage = resp.page
|
|
hasNext = resp.hasNext
|
|
} catch {
|
|
if !(error is CancellationError) {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
func loadNextPage() async {
|
|
guard hasNext, !isLoadingMore, !isLoading else { return }
|
|
isLoadingMore = true
|
|
|
|
let next = currentPage + 1
|
|
do {
|
|
let resp = try await APIClient.shared.browse(
|
|
page: next, genre: genre, sort: sort, status: status)
|
|
novels += resp.novels
|
|
currentPage = resp.page
|
|
hasNext = resp.hasNext
|
|
} catch {
|
|
// Silently ignore — user can scroll again
|
|
}
|
|
isLoadingMore = false
|
|
}
|
|
|
|
// MARK: - Ranking load (for rank sort mode)
|
|
|
|
func loadRanking() async {
|
|
guard !isLoading else { return }
|
|
novels = []
|
|
hasNext = false
|
|
isLoading = true
|
|
error = nil
|
|
|
|
do {
|
|
let items = try await APIClient.shared.ranking()
|
|
novels = items.map { BrowseNovelFromRanking($0) }
|
|
} catch {
|
|
if !(error is CancellationError) {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
// MARK: - RankingItem → BrowseNovel adapter
|
|
|
|
private func BrowseNovelFromRanking(_ item: RankingItem) -> BrowseNovel {
|
|
// Synthesise a minimal JSON blob so we can decode via the standard init
|
|
let rankStr = "#\(item.rank)"
|
|
let dict: [String: Any] = [
|
|
"slug": item.slug,
|
|
"title": item.title,
|
|
"cover": item.cover,
|
|
"rank": rankStr,
|
|
"rating": "",
|
|
"chapters": "",
|
|
"url": item.sourceURL,
|
|
"author": item.author,
|
|
"status": item.status,
|
|
"genres": item.genres
|
|
]
|
|
let data = try! JSONSerialization.data(withJSONObject: dict)
|
|
return try! JSONDecoder.apiDecoder.decode(BrowseNovel.self, from: data)
|
|
}
|