Files
libnovel/ios/LibNovelV2/ViewModels/LibraryViewModel.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

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
}
}
}