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

412 lines
14 KiB
Swift

import SwiftUI
// MARK: - BrowseView
// "Discover" tab: curated horizontal shelves (Trending, New, Updated, Ranking)
// plus a genre picker sheet. Mirrors the web UI's serendipitous browse experience.
struct BrowseView: View {
@State private var vm = BrowseViewModel()
@State private var showGenreSheet = false
@EnvironmentObject private var networkMonitor: NetworkMonitor
var body: some View {
NavigationStack {
VStack(spacing: 0) {
OfflineBanner()
Group {
if vm.isLoading && vm.trending.isEmpty {
loadingState
} else if let err = vm.error, vm.trending.isEmpty {
errorState(message: err)
} else {
shelvesContent
}
}
}
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
.navigationTitle("Discover")
.navigationBarTitleDisplayMode(.large)
.appNavigationDestination()
.task {
guard networkMonitor.isConnected else { return }
if vm.trending.isEmpty { await vm.loadShelves() }
}
.refreshable { await vm.loadShelves() }
.errorAlert($vm.error)
.sheet(isPresented: $showGenreSheet) {
GenrePickerSheet()
}
}
}
// MARK: - Shelves content
private var shelvesContent: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 32) {
// Trending Now
if !vm.trending.isEmpty {
BrowseShelf(
title: "Trending Now",
novels: vm.trending,
destination: NavDestination.browseCategory(
sort: "popular", genre: "all", status: "all", title: "Trending Now"
)
)
}
// New Releases
if !vm.newReleases.isEmpty {
BrowseShelf(
title: "New Releases",
novels: vm.newReleases,
destination: NavDestination.browseCategory(
sort: "new", genre: "all", status: "all", title: "New Releases"
)
)
}
// Recently Updated
if !vm.recentlyUpdated.isEmpty {
BrowseShelf(
title: "Recently Updated",
novels: vm.recentlyUpdated,
destination: NavDestination.browseCategory(
sort: "update", genre: "all", status: "all", title: "Recently Updated"
)
)
}
// Rankings (list-style shelf)
if !vm.ranking.isEmpty {
BrowseShelf(
title: "Rankings",
novels: vm.ranking,
destination: NavDestination.browseCategory(
sort: "rank", genre: "all", status: "all", title: "Rankings"
),
showRank: true
)
}
// Browse by Genre
CategoriesRow { showGenreSheet = true }
.padding(.horizontal, 16)
Color.clear.frame(height: 120)
}
.padding(.top, 8)
}
}
// MARK: - Loading / error states
private var loadingState: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 32) {
ForEach(0..<3, id: \.self) { _ in
BrowseShelfSkeleton()
}
}
.padding(.top, 8)
}
}
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 { await vm.loadShelves() } }
)
Spacer()
}
}
}
// MARK: - BrowseShelf
// Amber-accented header + horizontal card scroll + "See All" link.
struct BrowseShelf: View {
let title: String
let novels: [BrowseNovel]
let destination: NavDestination
var showRank: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header row
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color.amber)
.frame(width: 3, height: 18)
Text(title)
.font(.title3.bold())
Spacer()
NavigationLink(value: destination) {
HStack(spacing: 4) {
Text("See All")
.font(.subheadline)
Image(systemName: "chevron.right")
.font(.caption.bold())
}
.foregroundStyle(Color.amber)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
// Horizontal scroll
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 12) {
ForEach(novels) { novel in
NavigationLink(value: NavDestination.book(novel.slug)) {
BrowseShelfCard(novel: novel, showRank: showRank)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
}
}
}
}
// MARK: - BrowseShelfCard
struct BrowseShelfCard: View {
let novel: BrowseNovel
var showRank: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .topLeading) {
AsyncCoverImage(url: novel.cover)
.frame(width: 120, height: 173)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.bookCoverZoomSource(slug: novel.slug)
if showRank && !novel.rank.isEmpty {
Text(novel.rank)
.font(.caption2.bold())
.foregroundStyle(Color.amber)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
} else if !novel.rank.isEmpty {
Text(novel.rank)
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(novel.title)
.font(.caption.bold())
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(width: 120, alignment: .leading)
if !novel.author.isEmpty {
Text(novel.author)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: 120, alignment: .leading)
} else if !novel.chapters.isEmpty {
Text(novel.chapters)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: 120, alignment: .leading)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 8)
}
.frame(width: 132)
.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: - BrowseShelfSkeleton
struct BrowseShelfSkeleton: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header skeleton
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.amber.opacity(0.3))
.frame(width: 3, height: 18)
RoundedRectangle(cornerRadius: 6)
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
.frame(width: 140, height: 20)
Spacer()
}
.padding(.horizontal, 16)
// Cards skeleton
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(0..<5, id: \.self) { _ in
RoundedRectangle(cornerRadius: 14)
.fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1)))
.frame(width: 132, height: 220)
}
}
.padding(.horizontal, 16)
}
}
}
}
// MARK: - CategoriesRow
struct CategoriesRow: View {
let onTap: () -> Void
var body: some View {
Button(action: {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
onTap()
}) {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.amber.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: "square.grid.2x2")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(Color.amber)
}
VStack(alignment: .leading, spacing: 2) {
Text("Browse by Genre")
.font(.body.weight(.semibold))
.foregroundStyle(.primary)
Text("Action, Fantasy, Romance & more")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.tertiary)
}
.padding(14)
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("Browse by Genre")
}
}
// MARK: - GenrePickerSheet
struct GenrePickerSheet: View {
@Environment(\.dismiss) private var dismiss
private let genres: [(label: String, value: String, icon: String)] = [
("All Novels", "all", "books.vertical.fill"),
("Action", "action", "bolt.fill"),
("Adventure", "adventure", "map.fill"),
("Comedy", "comedy", "face.smiling.fill"),
("Drama", "drama", "theatermasks.fill"),
("Fantasy", "fantasy", "wand.and.stars"),
("Harem", "harem", "person.3.fill"),
("Historical", "historical", "building.columns.fill"),
("Horror", "horror", "moon.fill"),
("Isekai", "isekai", "globe.americas.fill"),
("Martial Arts", "martial-arts", "figure.martial.arts"),
("Mystery", "mystery", "magnifyingglass"),
("Psychological","psychological","brain.head.profile"),
("Romance", "romance", "heart.fill"),
("Sci-Fi", "sci-fi", "sparkles"),
("System", "system", "cpu"),
("Xianxia", "xianxia", "leaf.fill"),
]
private let columns = [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(genres, id: \.value) { item in
NavigationLink(value: NavDestination.browseCategory(
sort: "popular",
genre: item.value,
status: "all",
title: item.label
)) {
GenreTile(label: item.label, icon: item.icon)
}
.buttonStyle(.plain)
.simultaneousGesture(TapGesture().onEnded { dismiss() })
}
}
.padding(16)
.padding(.bottom, 20)
}
.navigationTitle("Genres")
.navigationBarTitleDisplayMode(.large)
.appNavigationDestination()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
.foregroundStyle(Color.amber)
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationCornerRadius(20)
}
}
// MARK: - GenreTile
private struct GenreTile: View {
let label: String
let icon: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.amber)
.frame(width: 24)
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.frame(minHeight: 44)
}
}