
- 在APIEndpoints.swift中新增publishFeed端点以支持发布动态。 - 新增PublishFeedRequest和PublishFeedResponse模型,处理发布请求和响应。 - 在EditFeedFeature中实现动态编辑功能,支持用户输入和发布内容。 - 更新CreateFeedView和EditFeedView以集成新的发布功能,提升用户体验。 - 在Localizable.strings中添加相关文本的本地化支持,确保多语言兼容性。 - 优化FeedListView和FeedView以展示最新动态,增强用户交互体验。
637 lines
24 KiB
Swift
637 lines
24 KiB
Swift
import SwiftUI
|
||
import ComposableArchitecture
|
||
|
||
struct FeedTopBarView: View {
|
||
let store: StoreOf<FeedFeature>
|
||
let onShowCreateFeed: () -> Void
|
||
var body: some View {
|
||
WithPerceptionTracking {
|
||
HStack {
|
||
Spacer()
|
||
Text(NSLocalizedString("feed.title", comment: "Enjoy your Life Time"))
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
Spacer()
|
||
Button(action: {
|
||
// showEditFeed = true // 显示编辑界面
|
||
}) {
|
||
Image("add icon")
|
||
.frame(width: 36, height: 36)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct FeedMomentsListView: View {
|
||
let store: StoreOf<FeedFeature>
|
||
var body: some View {
|
||
WithPerceptionTracking {
|
||
LazyVStack(spacing: 16) {
|
||
if store.moments.isEmpty {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "heart.text.square")
|
||
.font(.system(size: 40))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
Text(NSLocalizedString("feed.empty", comment: "No moments yet"))
|
||
.font(.system(size: 16))
|
||
.foregroundColor(.white.opacity(0.8))
|
||
if let error = store.error {
|
||
Text(String(format: NSLocalizedString("feed.error", comment: "Error: %@"), error))
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.red.opacity(0.8))
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal, 20)
|
||
}
|
||
|
||
// 重试按钮
|
||
if store.error != nil {
|
||
Button(action: {
|
||
store.send(.retryLoad)
|
||
}) {
|
||
Text(NSLocalizedString("feed.retry", comment: "Retry"))
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 8)
|
||
.background(Color.blue.opacity(0.8))
|
||
.cornerRadius(8)
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
.padding(.top, 40)
|
||
} else {
|
||
ForEach(Array(store.moments.enumerated()), id: \.element.dynamicId) { index, moment in
|
||
WithPerceptionTracking {
|
||
Text(moment.avatar)
|
||
// OptimizedDynamicCardView(
|
||
// moment: moment,
|
||
// allMoments: store.moments,
|
||
// currentIndex: index
|
||
// )
|
||
.onAppear {
|
||
// 当显示最后一个动态时,加载更多数据
|
||
if index == store.moments.count - 1 && store.hasMoreData && !store.isLoading {
|
||
store.send(.loadMoreMoments)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载更多指示器
|
||
if store.isLoading && !store.moments.isEmpty {
|
||
HStack {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
Text(NSLocalizedString("feed.loadingMore", comment: "Loading more..."))
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white.opacity(0.8))
|
||
}
|
||
.padding(.top, 20)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 20) // 调整顶部间距
|
||
}
|
||
}
|
||
}
|
||
|
||
struct FeedView: View {
|
||
let store: StoreOf<FeedFeature>
|
||
let onShowCreateFeed: () -> Void
|
||
@State private var showEditFeed = false
|
||
|
||
var body: some View {
|
||
WithPerceptionTracking {
|
||
GeometryReader { geometry in
|
||
ZStack {
|
||
// 背景图片 - 与 HomeView 保持一致
|
||
Image("bg")
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.clipped()
|
||
.ignoresSafeArea(.all)
|
||
|
||
// 主要内容布局
|
||
VStack(spacing: 0) {
|
||
// 固定内容区域
|
||
VStack(spacing: 20) {
|
||
FeedTopBarView(store: store, onShowCreateFeed: onShowCreateFeed)
|
||
Image(systemName: "heart.fill")
|
||
.font(.system(size: 60))
|
||
.foregroundColor(.red)
|
||
.padding(.top, 40)
|
||
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
||
.font(.system(size: 16))
|
||
.multilineTextAlignment(.center)
|
||
.foregroundColor(.white.opacity(0.9))
|
||
.padding(.horizontal, 30)
|
||
.padding(.bottom, 30)
|
||
}
|
||
// .padding(.top, 60) // 为状态栏留出空间
|
||
|
||
// 滚动内容区域 - 只有动态列表
|
||
ScrollView {
|
||
FeedMomentsListView(store: store)
|
||
.padding(.bottom, 20) // 底部留出空间
|
||
}
|
||
.refreshable {
|
||
// 下拉刷新
|
||
await withCheckedContinuation { continuation in
|
||
store.send(.refresh)
|
||
// 简单延迟确保刷新完成
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
continuation.resume()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
store.send(.onAppear)
|
||
}
|
||
.sheet(isPresented: $showEditFeed) {
|
||
EditFeedView(
|
||
onDismiss: {
|
||
showEditFeed = false
|
||
},
|
||
store: Store(
|
||
initialState: EditFeedFeature.State()
|
||
) {
|
||
EditFeedFeature()
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 优化的动态卡片组件
|
||
struct OptimizedDynamicCardView: View {
|
||
let moment: MomentsInfo
|
||
let allMoments: [MomentsInfo]
|
||
let currentIndex: Int
|
||
|
||
var body: some View {
|
||
WithPerceptionTracking{
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
// 用户信息
|
||
HStack {
|
||
// 使用缓存的头像
|
||
CachedAsyncImage(url: moment.avatar) { image in
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} placeholder: {
|
||
Circle()
|
||
.fill(Color.gray.opacity(0.3))
|
||
.overlay(
|
||
Text(String(moment.nick.prefix(1)))
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(.white)
|
||
)
|
||
}
|
||
.frame(width: 40, height: 40)
|
||
.clipShape(Circle())
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(moment.nick)
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(.white)
|
||
|
||
Text(formatTime(moment.publishTime))
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// VIP 标识
|
||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||
Text("VIP\(vipLevel)")
|
||
.font(.system(size: 10, weight: .bold))
|
||
.foregroundColor(.yellow)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(Color.yellow.opacity(0.2))
|
||
.cornerRadius(4)
|
||
}
|
||
}
|
||
|
||
// 动态内容
|
||
if !moment.content.isEmpty {
|
||
Text(moment.content)
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white.opacity(0.9))
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
|
||
// 优化的图片网格
|
||
if let images = moment.dynamicResList, !images.isEmpty {
|
||
OptimizedImageGrid(images: images)
|
||
}
|
||
|
||
// 互动按钮
|
||
HStack(spacing: 20) {
|
||
Button(action: {}) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "message")
|
||
.font(.system(size: 16))
|
||
Text("\(moment.commentCount)")
|
||
.font(.system(size: 14))
|
||
}
|
||
.foregroundColor(.white.opacity(0.8))
|
||
}
|
||
|
||
Button(action: {}) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||
.font(.system(size: 16))
|
||
Text("\(moment.likeCount)")
|
||
.font(.system(size: 14))
|
||
}
|
||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(
|
||
Color.white.opacity(0.1)
|
||
.cornerRadius(12)
|
||
)
|
||
.onAppear {
|
||
// 预加载相邻的图片
|
||
preloadNearbyImages()
|
||
}
|
||
}
|
||
|
||
private func formatTime(_ timestamp: Int) -> String {
|
||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "zh_CN")
|
||
|
||
let now = Date()
|
||
let interval = now.timeIntervalSince(date)
|
||
|
||
if interval < 60 {
|
||
return "刚刚"
|
||
} else if interval < 3600 {
|
||
return "\(Int(interval / 60))分钟前"
|
||
} else if interval < 86400 {
|
||
return "\(Int(interval / 3600))小时前"
|
||
} else {
|
||
formatter.dateFormat = "MM-dd HH:mm"
|
||
return formatter.string(from: date)
|
||
}
|
||
}
|
||
|
||
private func preloadNearbyImages() {
|
||
var urlsToPreload: [String] = []
|
||
|
||
// 预加载前后2个动态的图片
|
||
let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
|
||
|
||
for index in preloadRange {
|
||
let moment = allMoments[index]
|
||
|
||
// 添加头像
|
||
urlsToPreload.append(moment.avatar)
|
||
|
||
// 添加动态图片
|
||
if let images = moment.dynamicResList {
|
||
urlsToPreload.append(contentsOf: images.map { $0.resUrl })
|
||
}
|
||
}
|
||
|
||
// 异步预加载
|
||
ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
|
||
}
|
||
}
|
||
|
||
// MARK: - 优化的图片网格
|
||
struct OptimizedImageGrid: View {
|
||
let images: [MomentsPicture]
|
||
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
let availableWidth = max(geometry.size.width, 1) // 防止为0或负数
|
||
let spacing: CGFloat = 8
|
||
|
||
// 保护:如果availableWidth不合理,直接返回空视图
|
||
if availableWidth < 10 {
|
||
Color.clear.frame(height: 1)
|
||
} else {
|
||
switch images.count {
|
||
case 1:
|
||
// 单张图片:大正方形居中显示
|
||
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||
HStack {
|
||
Spacer()
|
||
SquareImageView(image: images[0], size: imageSize)
|
||
Spacer()
|
||
}
|
||
case 2:
|
||
// 两张图片:并排显示
|
||
let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||
HStack(spacing: spacing) {
|
||
SquareImageView(image: images[0], size: imageSize)
|
||
SquareImageView(image: images[1], size: imageSize)
|
||
}
|
||
case 3:
|
||
// 三张图片:水平排列
|
||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||
HStack(spacing: spacing) {
|
||
ForEach(images.prefix(3), id: \.id) { image in
|
||
SquareImageView(image: image, size: imageSize)
|
||
}
|
||
}
|
||
default:
|
||
// 四张及以上:九宫格布局(最多9张)
|
||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||
LazyVGrid(columns: columns, spacing: spacing) {
|
||
ForEach(images.prefix(9), id: \.id) { image in
|
||
SquareImageView(image: image, size: imageSize)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(height: calculateGridHeight())
|
||
}
|
||
|
||
private func calculateGridHeight() -> CGFloat {
|
||
switch images.count {
|
||
case 1:
|
||
return 200 // 单张图片的最大高度
|
||
case 2:
|
||
return 120 // 两张图片并排的高度
|
||
case 3:
|
||
return 100 // 三张图片水平排列的高度
|
||
case 4...6:
|
||
return 216 // 九宫格2行的高度 (实际图片大小 * 2 + 间距)
|
||
default:
|
||
return 340 // 九宫格3行的高度 (实际图片大小 * 3 + 间距 + 额外空间)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 正方形图片视图组件
|
||
struct SquareImageView: View {
|
||
let image: MomentsPicture
|
||
let size: CGFloat
|
||
|
||
var body: some View {
|
||
let safeSize = size.isFinite && size > 0 ? size : 100 // 防止非有限或负数
|
||
CachedAsyncImage(url: image.resUrl) { imageView in
|
||
imageView
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} placeholder: {
|
||
Rectangle()
|
||
.fill(Color.gray.opacity(0.3))
|
||
.overlay(
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
||
.scaleEffect(0.8)
|
||
)
|
||
}
|
||
.frame(width: safeSize, height: safeSize)
|
||
.clipped()
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
|
||
// MARK: - 旧的真实动态卡片组件(保留备用)
|
||
struct RealDynamicCardView: View {
|
||
let moment: MomentsInfo
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
// 用户信息
|
||
HStack {
|
||
AsyncImage(url: URL(string: moment.avatar)) { image in
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} placeholder: {
|
||
Circle()
|
||
.fill(Color.gray.opacity(0.3))
|
||
.overlay(
|
||
Text(String(moment.nick.prefix(1)))
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(.white)
|
||
)
|
||
}
|
||
.frame(width: 40, height: 40)
|
||
.clipShape(Circle())
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(moment.nick)
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(.white)
|
||
|
||
Text(formatTime(moment.publishTime))
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// VIP 标识
|
||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||
Text("VIP\(vipLevel)")
|
||
.font(.system(size: 10, weight: .bold))
|
||
.foregroundColor(.yellow)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(Color.yellow.opacity(0.2))
|
||
.cornerRadius(4)
|
||
}
|
||
}
|
||
|
||
// 动态内容
|
||
if !moment.content.isEmpty {
|
||
Text(moment.content)
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white.opacity(0.9))
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
|
||
// 图片网格
|
||
if let images = moment.dynamicResList, !images.isEmpty {
|
||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
|
||
ForEach(images.prefix(9), id: \.id) { image in
|
||
AsyncImage(url: URL(string: image.resUrl)) { imageView in
|
||
imageView
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} placeholder: {
|
||
Rectangle()
|
||
.fill(Color.gray.opacity(0.3))
|
||
.overlay(
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
||
)
|
||
}
|
||
.frame(height: 100)
|
||
.clipped()
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 互动按钮
|
||
HStack(spacing: 20) {
|
||
Button(action: {}) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "message")
|
||
.font(.system(size: 16))
|
||
Text("\(moment.commentCount)")
|
||
.font(.system(size: 14))
|
||
}
|
||
.foregroundColor(.white.opacity(0.8))
|
||
}
|
||
|
||
Button(action: {}) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||
.font(.system(size: 16))
|
||
Text("\(moment.likeCount)")
|
||
.font(.system(size: 14))
|
||
}
|
||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
.padding(16)
|
||
.background(
|
||
Color.white.opacity(0.1)
|
||
.cornerRadius(12)
|
||
)
|
||
}
|
||
|
||
private func formatTime(_ timestamp: Int) -> String {
|
||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "zh_CN")
|
||
|
||
let now = Date()
|
||
let interval = now.timeIntervalSince(date)
|
||
|
||
if interval < 60 {
|
||
return "刚刚"
|
||
} else if interval < 3600 {
|
||
return "\(Int(interval / 60))分钟前"
|
||
} else if interval < 86400 {
|
||
return "\(Int(interval / 3600))小时前"
|
||
} else {
|
||
formatter.dateFormat = "MM-dd HH:mm"
|
||
return formatter.string(from: date)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 旧的模拟卡片组件(保留备用)
|
||
struct DynamicCardView: View {
|
||
let index: Int
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
// 用户信息
|
||
HStack {
|
||
Circle()
|
||
.fill(Color.gray.opacity(0.3))
|
||
.frame(width: 40, height: 40)
|
||
.overlay(
|
||
Text("U\(index + 1)")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(.white)
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("用户\(index + 1)")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(.white)
|
||
|
||
Text("2小时前")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
|
||
// 动态内容
|
||
Text("今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white.opacity(0.9))
|
||
.multilineTextAlignment(.leading)
|
||
|
||
// 图片网格
|
||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
|
||
ForEach(0..<3) { imageIndex in
|
||
Rectangle()
|
||
.fill(Color.gray.opacity(0.3))
|
||
.aspectRatio(1, contentMode: .fit)
|
||
.overlay(
|
||
Image(systemName: "photo")
|
||
.foregroundColor(.white.opacity(0.6))
|
||
)
|
||
}
|
||
}
|
||
|
||
// 互动按钮
|
||
HStack(spacing: 20) {
|
||
Button(action: {}) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "message")
|
||
.font(.system(size: 16))
|
||
Text("354")
|
||
.font(.system(size: 14))
|
||
}
|
||
.foregroundColor(.white.opacity(0.8))
|
||
}
|
||
|
||
Button(action: {}) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "heart")
|
||
.font(.system(size: 16))
|
||
Text("354")
|
||
.font(.system(size: 14))
|
||
}
|
||
.foregroundColor(.white.opacity(0.8))
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
.padding(16)
|
||
.background(
|
||
Color.white.opacity(0.1)
|
||
.cornerRadius(12)
|
||
)
|
||
}
|
||
}
|
||
|
||
//#Preview {
|
||
// FeedView(
|
||
// store: Store(initialState: FeedFeature.State()) {
|
||
// FeedFeature()
|
||
// }
|
||
// )
|
||
//}
|