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
447 lines
14 KiB
Swift
447 lines
14 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - BrowseCategoryView
|
||
// Full paginated grid for "See All" / genre deep-dives.
|
||
// Supports browse (infinite scroll) and rank (flat list) modes.
|
||
// Sort/genre/status can be adjusted via the filters sheet.
|
||
|
||
struct BrowseCategoryView: View {
|
||
let sort: String
|
||
let genre: String
|
||
let status: String
|
||
let title: String
|
||
|
||
@State private var vm = BrowseViewModel()
|
||
@State private var showFilters = false
|
||
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||
|
||
init(sort: String, genre: String, status: String, title: String) {
|
||
self.sort = sort
|
||
self.genre = genre
|
||
self.status = status
|
||
self.title = title
|
||
}
|
||
|
||
private var isRankMode: Bool { sort == "rank" }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if vm.isLoading && vm.novels.isEmpty {
|
||
loadingState
|
||
} else if let err = vm.error, vm.novels.isEmpty {
|
||
errorState(message: err)
|
||
} else if vm.novels.isEmpty && !vm.isLoading {
|
||
emptyState
|
||
} else if isRankMode {
|
||
rankList
|
||
} else {
|
||
novelGrid
|
||
}
|
||
}
|
||
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||
.navigationTitle(title)
|
||
.navigationBarTitleDisplayMode(.large)
|
||
.appNavigationDestination()
|
||
.toolbar { toolbarContent }
|
||
.task {
|
||
guard networkMonitor.isConnected else { return }
|
||
vm.sort = sort
|
||
vm.genre = genre
|
||
vm.status = status
|
||
if vm.novels.isEmpty {
|
||
if isRankMode {
|
||
await vm.loadRanking()
|
||
} else {
|
||
await vm.loadFirstPage()
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: vm.sort) { _, _ in
|
||
Task { await refreshForFilters() }
|
||
}
|
||
.onChange(of: vm.genre) { _, _ in
|
||
Task { await refreshForFilters() }
|
||
}
|
||
.onChange(of: vm.status) { _, _ in
|
||
Task { await refreshForFilters() }
|
||
}
|
||
.sheet(isPresented: $showFilters) {
|
||
BrowseFiltersSheet(vm: vm)
|
||
}
|
||
.errorAlert($vm.error)
|
||
}
|
||
|
||
// MARK: - Grid view
|
||
|
||
private let columns = [
|
||
GridItem(.flexible(), spacing: 14),
|
||
GridItem(.flexible(), spacing: 14)
|
||
]
|
||
|
||
private var novelGrid: some View {
|
||
ScrollView {
|
||
LazyVGrid(columns: columns, spacing: 14) {
|
||
ForEach(vm.novels) { novel in
|
||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||
BrowseCategoryCard(novel: novel)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onAppear {
|
||
if novel.id == vm.novels.last?.id && vm.hasNext {
|
||
Task { await vm.loadNextPage() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 12)
|
||
|
||
// Load-more indicator
|
||
if vm.isLoadingMore {
|
||
ProgressView()
|
||
.padding(.vertical, 24)
|
||
.tint(Color.amber)
|
||
} else if !vm.hasNext && !vm.novels.isEmpty {
|
||
Text("All novels loaded")
|
||
.font(.caption)
|
||
.foregroundStyle(.quaternary)
|
||
.padding(.vertical, 24)
|
||
}
|
||
|
||
Color.clear.frame(height: 120)
|
||
}
|
||
.refreshable { await vm.loadFirstPage() }
|
||
}
|
||
|
||
// MARK: - Rank list view
|
||
|
||
private var rankList: some View {
|
||
List {
|
||
ForEach(vm.novels) { novel in
|
||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||
RankListRow(novel: novel)
|
||
}
|
||
.listRowBackground(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||
.listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollContentBackground(.hidden)
|
||
.refreshable { await vm.loadRanking() }
|
||
}
|
||
|
||
// MARK: - Loading / error / empty
|
||
|
||
private var loadingState: some View {
|
||
ScrollView {
|
||
LazyVGrid(columns: columns, spacing: 14) {
|
||
ForEach(0..<10, id: \.self) { _ in
|
||
BrowseCategoryCardSkeleton()
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 12)
|
||
}
|
||
}
|
||
|
||
private func errorState(message: String) -> some View {
|
||
VStack(spacing: 16) {
|
||
Spacer()
|
||
EmptyStateView(
|
||
icon: "wifi.slash",
|
||
title: "Couldn't load",
|
||
message: message,
|
||
ctaLabel: "Retry",
|
||
ctaAction: {
|
||
Task {
|
||
if isRankMode { await vm.loadRanking() }
|
||
else { await vm.loadFirstPage() }
|
||
}
|
||
}
|
||
)
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack {
|
||
Spacer()
|
||
EmptyStateView(
|
||
icon: "books.vertical",
|
||
title: "No novels found",
|
||
message: "Try different filters.",
|
||
ctaLabel: "Change Filters",
|
||
ctaAction: { showFilters = true }
|
||
)
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
// MARK: - Toolbar
|
||
|
||
@ToolbarContentBuilder
|
||
private var toolbarContent: some ToolbarContent {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
showFilters = true
|
||
} label: {
|
||
Image(systemName: "slider.horizontal.3")
|
||
.foregroundStyle(Color.amber)
|
||
}
|
||
.accessibilityLabel("Filter novels")
|
||
}
|
||
}
|
||
|
||
// MARK: - Filter change
|
||
|
||
private func refreshForFilters() async {
|
||
if vm.sort == "rank" {
|
||
await vm.loadRanking()
|
||
} else {
|
||
await vm.loadFirstPage()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - BrowseCategoryCard
|
||
|
||
struct BrowseCategoryCard: View {
|
||
let novel: BrowseNovel
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
ZStack(alignment: .topLeading) {
|
||
AsyncCoverImage(url: novel.cover)
|
||
.frame(maxWidth: .infinity)
|
||
.aspectRatio(2/3, contentMode: .fit)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
.bookCoverZoomSource(slug: novel.slug)
|
||
|
||
if !novel.rank.isEmpty {
|
||
Text(novel.rank)
|
||
.font(.caption2.bold())
|
||
.foregroundStyle(Color.amber)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 3)
|
||
.background(.ultraThinMaterial, in: Capsule())
|
||
.padding(6)
|
||
}
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(novel.title)
|
||
.font(.subheadline.bold())
|
||
.lineLimit(2)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
if !novel.author.isEmpty {
|
||
Text(novel.author)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
|
||
if !novel.chapters.isEmpty {
|
||
Text(novel.chapters)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 10)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2)
|
||
}
|
||
}
|
||
|
||
// MARK: - BrowseCategoryCardSkeleton
|
||
|
||
private struct BrowseCategoryCardSkeleton: View {
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1)))
|
||
.aspectRatio(2/3, contentMode: .fit)
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
RoundedRectangle(cornerRadius: 4)
|
||
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||
.frame(height: 14)
|
||
RoundedRectangle(cornerRadius: 4)
|
||
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||
.frame(width: 80, height: 11)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 10)
|
||
}
|
||
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
}
|
||
}
|
||
|
||
// MARK: - RankListRow
|
||
|
||
private struct RankListRow: View {
|
||
let novel: BrowseNovel
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
// Rank number
|
||
Text(novel.rank.isEmpty ? "–" : novel.rank)
|
||
.font(.subheadline.bold())
|
||
.foregroundStyle(Color.amber)
|
||
.frame(width: 36, alignment: .trailing)
|
||
|
||
// Cover thumbnail
|
||
AsyncCoverImage(url: novel.cover)
|
||
.frame(width: 44, height: 62)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||
|
||
// Title + meta
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(novel.title)
|
||
.font(.subheadline.bold())
|
||
.lineLimit(2)
|
||
.foregroundStyle(.primary)
|
||
|
||
if !novel.author.isEmpty {
|
||
Text(novel.author)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
|
||
HStack(spacing: 6) {
|
||
if !novel.status.isEmpty {
|
||
TagChip(label: novel.status.capitalized)
|
||
}
|
||
if !novel.rating.isEmpty {
|
||
TagChip(label: "★ \(novel.rating)")
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.vertical, 6)
|
||
.frame(minHeight: 44)
|
||
}
|
||
}
|
||
|
||
// MARK: - BrowseFiltersSheet
|
||
|
||
struct BrowseFiltersSheet: View {
|
||
var vm: BrowseViewModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
private let sortOptions: [(value: String, label: String)] = [
|
||
("popular", "Popular"),
|
||
("new", "New"),
|
||
("update", "Updated"),
|
||
("rank", "Ranking"),
|
||
]
|
||
private let genreOptions: [(value: String, label: String)] = [
|
||
("all", "All Genres"),
|
||
("action", "Action"),
|
||
("adventure", "Adventure"),
|
||
("comedy", "Comedy"),
|
||
("drama", "Drama"),
|
||
("fantasy", "Fantasy"),
|
||
("harem", "Harem"),
|
||
("historical", "Historical"),
|
||
("horror", "Horror"),
|
||
("isekai", "Isekai"),
|
||
("martial-arts", "Martial Arts"),
|
||
("mystery", "Mystery"),
|
||
("psychological", "Psychological"),
|
||
("romance", "Romance"),
|
||
("sci-fi", "Sci-Fi"),
|
||
("system", "System"),
|
||
("xianxia", "Xianxia"),
|
||
]
|
||
private let statusOptions: [(value: String, label: String)] = [
|
||
("all", "All"),
|
||
("ongoing", "Ongoing"),
|
||
("completed", "Completed"),
|
||
]
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("Sort") {
|
||
ForEach(sortOptions, id: \.value) { opt in
|
||
filterRow(label: opt.label, isSelected: vm.sort == opt.value) {
|
||
vm.sort = opt.value
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
|
||
Section("Genre") {
|
||
ForEach(genreOptions, id: \.value) { opt in
|
||
filterRow(label: opt.label, isSelected: vm.genre == opt.value) {
|
||
vm.genre = opt.value
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
.disabled(vm.sort == "rank")
|
||
|
||
Section("Status") {
|
||
ForEach(statusOptions, id: \.value) { opt in
|
||
filterRow(label: opt.label, isSelected: vm.status == opt.value) {
|
||
vm.status = opt.value
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
.disabled(vm.sort == "rank")
|
||
|
||
if vm.sort == "rank" {
|
||
Section {
|
||
Text("Genre & status filters apply to Browse only")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Filters")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") { dismiss() }
|
||
.fontWeight(.semibold)
|
||
.foregroundStyle(Color.amber)
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func filterRow(label: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||
HStack {
|
||
Text(label)
|
||
Spacer()
|
||
if isSelected {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(Color.amber)
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
action()
|
||
}
|
||
.frame(minHeight: 44)
|
||
}
|
||
}
|