Files
libnovel/ios/LibNovelV2/Views/Browse/BrowseCategoryView.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

447 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}