init: init proj
This commit is contained in:
84
DDYSClient/Views/Browse/BrowseView.swift
Normal file
84
DDYSClient/Views/Browse/BrowseView.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BrowseView: View {
|
||||
let category: ContentCategory
|
||||
@Bindable var viewModel: BrowseViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !viewModel.hasData && viewModel.error == nil {
|
||||
VStack(spacing: 0) {
|
||||
FilterBarView(filter: $viewModel.filter) {
|
||||
Task { await viewModel.applyFilter(viewModel.filter) }
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
FilterBarView(filter: $viewModel.filter) {
|
||||
Task { await viewModel.applyFilter(viewModel.filter) }
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if let error = viewModel.error {
|
||||
errorView(error)
|
||||
} else if viewModel.items.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
ContentGridView(items: viewModel.items) {
|
||||
Task { await viewModel.loadMore() }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(category.displayName)
|
||||
.navigationDestination(for: ContentItem.self) { item in
|
||||
DetailView(item: item)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.loadContent()
|
||||
}
|
||||
.task {
|
||||
viewModel.category = category
|
||||
await viewModel.loadContentIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func errorView(_ message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(message)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("重试") {
|
||||
Task { await viewModel.loadContent() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "film.stack")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("暂无内容")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
}
|
||||
}
|
||||
75
DDYSClient/Views/Browse/ContentCardView.swift
Normal file
75
DDYSClient/Views/Browse/ContentCardView.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentCardView: View {
|
||||
let item: ContentItem
|
||||
|
||||
private let cardWidth: CGFloat = 140
|
||||
private let aspectRatio: CGFloat = 2.0 / 3.0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// 海报
|
||||
ZStack(alignment: .topLeading) {
|
||||
posterImage
|
||||
.frame(width: cardWidth, height: cardWidth / aspectRatio)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
// 评分角标
|
||||
if let rating = item.rating, rating > 0 {
|
||||
Text(String(format: "%.1f", rating))
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.orange.gradient, in: RoundedRectangle(cornerRadius: 4))
|
||||
.padding(6)
|
||||
.frame(maxWidth: .infinity, alignment: .topTrailing)
|
||||
}
|
||||
|
||||
// 状态徽章
|
||||
if let badge = item.badges.first {
|
||||
Text(badge)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.blue.gradient, in: RoundedRectangle(cornerRadius: 4))
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
// 标题
|
||||
Text(item.title)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
.frame(width: cardWidth, alignment: .leading)
|
||||
|
||||
// 年份
|
||||
if item.year > 0 {
|
||||
Text("\(String(item.year))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: cardWidth)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var posterImage: some View {
|
||||
CachedAsyncImage(url: item.posterURL) {
|
||||
posterPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
private var posterPlaceholder: some View {
|
||||
Rectangle()
|
||||
.fill(.quaternary)
|
||||
.overlay {
|
||||
Image(systemName: "film")
|
||||
.font(.title)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
DDYSClient/Views/Browse/ContentGridView.swift
Normal file
31
DDYSClient/Views/Browse/ContentGridView.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentGridView: View {
|
||||
let items: [ContentItem]
|
||||
var onNearEnd: (() -> Void)?
|
||||
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(items) { item in
|
||||
NavigationLink(value: item) {
|
||||
ContentCardView(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
if let onNearEnd, isNearEnd(item) {
|
||||
onNearEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isNearEnd(_ item: ContentItem) -> Bool {
|
||||
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return false }
|
||||
return index >= items.count - 4
|
||||
}
|
||||
}
|
||||
55
DDYSClient/Views/Browse/FilterBarView.swift
Normal file
55
DDYSClient/Views/Browse/FilterBarView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FilterBarView: View {
|
||||
@Binding var filter: FilterState
|
||||
var onFilterChanged: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
filterMenu("排序", options: FilterState.defaultSorts, selection: $filter.sort)
|
||||
filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre)
|
||||
filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region)
|
||||
filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func filterMenu(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
|
||||
Menu {
|
||||
ForEach(options) { option in
|
||||
Button {
|
||||
selection.wrappedValue = option.value
|
||||
onFilterChanged()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(option.name)
|
||||
if selection.wrappedValue == option.value {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(selectedName(for: selection.wrappedValue, in: options) ?? title)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
selection.wrappedValue.isEmpty
|
||||
? Color.secondary.opacity(0.12)
|
||||
: Color.accentColor.opacity(0.15),
|
||||
in: Capsule()
|
||||
)
|
||||
.foregroundStyle(selection.wrappedValue.isEmpty ? Color.primary : Color.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedName(for value: String, in options: [FilterOption]) -> String? {
|
||||
guard !value.isEmpty else { return nil }
|
||||
return options.first { $0.value == value }?.name
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user