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
165 lines
4.7 KiB
Swift
165 lines
4.7 KiB
Swift
import Foundation
|
|
|
|
// MARK: - LibraryViewModel
|
|
// Loads library items and exposes filtered/sorted views for LibraryView.
|
|
// Uses @Observable (iOS 17+).
|
|
|
|
enum LibrarySortOrder: String, CaseIterable {
|
|
case recent = "Recent"
|
|
case title = "Title"
|
|
case author = "Author"
|
|
case progress = "Progress"
|
|
}
|
|
|
|
enum LibraryReadingFilter: String, CaseIterable {
|
|
case all = "All"
|
|
case inProgress = "In Progress"
|
|
case completed = "Completed"
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class LibraryViewModel {
|
|
// Raw data
|
|
var items: [LibraryItem] = []
|
|
var progressMap: [String: Int] = [:] // slug -> last chapter read
|
|
|
|
// Filter & sort state
|
|
var sortOrder: LibrarySortOrder = .recent
|
|
var readingFilter: LibraryReadingFilter = .all
|
|
var selectedGenre: String = "All"
|
|
|
|
// UI state
|
|
var isLoading = false
|
|
var error: String?
|
|
|
|
// MARK: - Derived
|
|
|
|
var allGenres: [String] {
|
|
var seen = Set<String>()
|
|
var result: [String] = ["All"]
|
|
for item in items {
|
|
for genre in item.book.genres where !seen.contains(genre) {
|
|
seen.insert(genre)
|
|
result.append(genre)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
var filteredItems: [LibraryItem] {
|
|
var list = items
|
|
|
|
// Genre filter
|
|
if selectedGenre != "All" {
|
|
list = list.filter { $0.book.genres.contains(selectedGenre) }
|
|
}
|
|
|
|
// Reading filter
|
|
switch readingFilter {
|
|
case .all:
|
|
break
|
|
case .inProgress:
|
|
list = list.filter { item in
|
|
let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
|
return ch > 0 && ch < item.book.totalChapters
|
|
}
|
|
case .completed:
|
|
list = list.filter { item in
|
|
let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
|
return item.book.totalChapters > 0 && ch >= item.book.totalChapters
|
|
}
|
|
}
|
|
|
|
// Sort
|
|
switch sortOrder {
|
|
case .recent:
|
|
// server already returns newest-saved first; preserve order
|
|
break
|
|
case .title:
|
|
list.sort { $0.book.title.localizedCaseInsensitiveCompare($1.book.title) == .orderedAscending }
|
|
case .author:
|
|
list.sort { $0.book.author.localizedCaseInsensitiveCompare($1.book.author) == .orderedAscending }
|
|
case .progress:
|
|
list.sort { a, b in
|
|
let pa = progressFraction(for: a)
|
|
let pb = progressFraction(for: b)
|
|
return pa > pb
|
|
}
|
|
}
|
|
|
|
return list
|
|
}
|
|
|
|
// MARK: - Progress helpers
|
|
|
|
func lastChapter(for item: LibraryItem) -> Int {
|
|
progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
|
}
|
|
|
|
func progressFraction(for item: LibraryItem) -> Double {
|
|
let total = item.book.totalChapters
|
|
guard total > 0 else { return 0 }
|
|
return Double(lastChapter(for: item)) / Double(total)
|
|
}
|
|
|
|
func progressPercent(for item: LibraryItem) -> String {
|
|
let fraction = progressFraction(for: item)
|
|
let pct = fraction * 100
|
|
if pct < 10 {
|
|
return String(format: "%.1f%%", pct)
|
|
} else {
|
|
return String(format: "%.0f%%", pct)
|
|
}
|
|
}
|
|
|
|
func isCompleted(for item: LibraryItem) -> Bool {
|
|
let total = item.book.totalChapters
|
|
guard total > 0 else { return false }
|
|
return lastChapter(for: item) >= total
|
|
}
|
|
|
|
// MARK: - Load
|
|
|
|
func load() async {
|
|
isLoading = true
|
|
error = nil
|
|
do {
|
|
async let libraryTask = APIClient.shared.library()
|
|
async let progressTask = APIClient.shared.progress()
|
|
|
|
let (library, progressEntries) = try await (libraryTask, progressTask)
|
|
items = library
|
|
progressMap = Dictionary(uniqueKeysWithValues: progressEntries.map { ($0.slug, $0.chapter) })
|
|
} catch {
|
|
if !(error is CancellationError) {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
// MARK: - Mutations
|
|
|
|
func removeFromLibrary(slug: String) async {
|
|
// Optimistic remove
|
|
items.removeAll { $0.book.slug == slug }
|
|
do {
|
|
try await APIClient.shared.unsaveBook(slug: slug)
|
|
} catch {
|
|
// Silently fail — user can pull-to-refresh to restore
|
|
}
|
|
}
|
|
|
|
func markFinished(item: LibraryItem) async {
|
|
let total = item.book.totalChapters
|
|
guard total > 0 else { return }
|
|
progressMap[item.book.slug] = total
|
|
do {
|
|
try await APIClient.shared.setProgress(slug: item.book.slug, chapter: total)
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}
|
|
}
|