Files
e-party-iOS/yana/MVVM/View/MomentListItem.swift
edwinQQQ 327d4fd218 feat: 实现动态详情页及相关功能
- 在MePage和MomentListHomePage中新增动态点击事件,支持打开动态详情页。
- 创建MomentDetailPage视图,展示动态详细信息,包括用户信息、动态内容和互动按钮。
- 实现MomentDetailViewModel,管理动态详情页的状态和点赞逻辑。
- 更新MomentListItem组件,添加整体点击回调,提升用户交互体验。
- 优化背景视图组件,确保一致的视觉效果。
2025-09-26 16:49:18 +08:00

422 lines
17 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: - MomentListItem
struct MomentListItem: View {
let moment: MomentsInfo
let onImageTap: (([String], Int)) -> Void //
let onMomentTap: (MomentsInfo) -> Void //
//
@State private var isLikeLoading = false
@State private var localIsLike: Bool
@State private var localLikeCount: Int
init(
moment: MomentsInfo,
onImageTap: @escaping (([String], Int)) -> Void = { (arg) in let (_, _) = arg; },
onMomentTap: @escaping (MomentsInfo) -> Void = { _ in }
) {
self.moment = moment
self.onImageTap = onImageTap
self.onMomentTap = onMomentTap
//
self._localIsLike = State(initialValue: moment.isLike)
self._localLikeCount = State(initialValue: moment.likeCount)
}
var body: some View {
let isReviewing = moment.status == 0
ZStack(alignment: .bottomTrailing) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
//
VStack(alignment: .leading, spacing: 10) {
//
HStack(alignment: .top) {
//
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)
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
}
Spacer()
//
Text(formatDisplayTime(moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
}
//
if let images = moment.dynamicResList, !images.isEmpty {
MomentImageGrid(
images: images,
onImageTap: onImageTap
)
.padding(.leading, 40 + 8)
.padding(.bottom, images.count == 2 ? 30 : 0) //
}
//
HStack(spacing: 20) {
// Like
Button(action: {
if !isLikeLoading && !isReviewing {
handleLikeTap()
}
}) {
HStack(spacing: 4) {
if isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: localIsLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: localIsLike ? "heart.fill" : "heart")
.font(.system(size: 16))
}
Text("\(localLikeCount)")
.font(.system(size: 14))
}
.foregroundColor(localIsLike ? .red : .white.opacity(0.8))
}
.disabled(isLikeLoading || isReviewing)
.opacity(isReviewing ? 0.5 : 1.0)
.padding(.leading, 40 + 8) // +
Spacer()
// -
if isReviewing {
Text("reviewing")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.85))
.clipShape(Capsule())
}
}
.padding(.top, 8)
}
.padding(16)
}
.contentShape(Rectangle())
.onTapGesture {
onMomentTap(moment)
}
}
}
// MARK: -
private func handleLikeTap() {
Task {
await performLikeRequest()
}
}
private func performLikeRequest() async {
//
await MainActor.run {
isLikeLoading = true
}
do {
// ID
guard let currentUserId = await UserInfoManager.getCurrentUserId(),
let currentUserIdInt = Int(currentUserId) else {
await MainActor.run {
isLikeLoading = false
}
setAPILoadingErrorSync(UUID(), errorMessage: "无法获取用户信息,请重新登录")
return
}
//
let status = localIsLike ? 0 : 1 // 0: , 1:
// API
let apiService = LiveAPIService()
//
let request = LikeDynamicRequest(
dynamicId: moment.dynamicId,
uid: currentUserIdInt,
status: status,
likedUid: moment.uid,
worldId: moment.worldId
)
debugInfoSync("📡 MomentListItem: 发送点赞请求")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 当前状态: \(localIsLike)")
debugInfoSync(" 请求状态: \(status)")
//
let response: LikeDynamicResponse = try await apiService.request(request)
await MainActor.run {
isLikeLoading = false
// , code
if response.code == 200 {
localIsLike = !localIsLike
localLikeCount = localIsLike ? localLikeCount+1 : localLikeCount-1
debugInfoSync("✅ MomentListItem: 点赞操作成功")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 新状态: \(localIsLike)")
debugInfoSync(" 新数量: \(localLikeCount)")
} else {
let errorMessage = response.message.isEmpty ? "点赞失败,请重试" : response.message
setAPILoadingErrorSync(UUID(), errorMessage: errorMessage)
debugErrorSync("❌ MomentListItem: 点赞操作失败")
debugErrorSync(" 动态ID: \(moment.dynamicId)")
debugErrorSync(" 错误: \(errorMessage)")
}
}
} catch {
await MainActor.run {
isLikeLoading = false
}
setAPILoadingErrorSync(UUID(), errorMessage: error.localizedDescription)
debugErrorSync("❌ MomentListItem: 点赞请求异常")
debugErrorSync(" 动态ID: \(moment.dynamicId)")
debugErrorSync(" 错误: \(error.localizedDescription)")
}
}
// MARK: -
private func formatDisplayTime(_ 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)
let calendar = Calendar.current
if calendar.isDateInToday(date) {
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else {
return "\(Int(interval / 3600))小时前"
}
} else {
formatter.dateFormat = "MM/dd"
return formatter.string(from: date)
}
}
}
// MARK: -
struct MomentImageGrid: View {
let images: [MomentsPicture]
let onImageTap: (([String], Int)) -> Void //
var body: some View {
GeometryReader { geometry in
let availableWidth = max(geometry.size.width, 1)
let spacing: CGFloat = 8
if availableWidth < 10 {
Color.clear.frame(height: 1)
} else {
switch images.count {
case 1:
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
HStack {
Spacer()
MomentSquareImageView(
image: images[0],
size: imageSize,
onTap: {
let imageUrls = images.compactMap { $0.resUrl }
onImageTap((imageUrls, 0))
}
)
Spacer()
}
case 2:
let imageSize: CGFloat = (availableWidth - spacing) / 2
HStack(spacing: spacing) {
MomentSquareImageView(
image: images[0],
size: imageSize,
onTap: {
let imageUrls = images.compactMap { $0.resUrl }
onImageTap((imageUrls, 0))
}
)
MomentSquareImageView(
image: images[1],
size: imageSize,
onTap: {
let imageUrls = images.compactMap { $0.resUrl }
onImageTap((imageUrls, 1))
}
)
}
case 3:
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
HStack(spacing: spacing) {
ForEach(Array(images.prefix(3).enumerated()), id: \.element.id) { index, image in
MomentSquareImageView(
image: image,
size: imageSize,
onTap: {
let imageUrls = images.compactMap { $0.resUrl }
onImageTap((imageUrls, index))
}
)
}
}
default:
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
LazyVGrid(columns: columns, spacing: spacing) {
ForEach(Array(images.prefix(9).enumerated()), id: \.element.id) { index, image in
MomentSquareImageView(
image: image,
size: imageSize,
onTap: {
let imageUrls = images.compactMap { $0.resUrl }
onImageTap((imageUrls, index))
}
)
}
}
}
}
}
.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
default:
return 340
}
}
}
// MARK: -
struct MomentSquareImageView: View {
let image: MomentsPicture
let size: CGFloat
let onTap: () -> Void //
var body: some View {
let safeSize = size.isFinite && size > 0 ? size : 100
Button(action: onTap) {
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)
}
.buttonStyle(PlainButtonStyle()) // 使PlainButtonStyle
}
}
//#Preview {
// //
// let testMoment = MomentsInfo(
// dynamicId: 1,
// uid: 123456,
// nick: "",
// avatar: "",
// type: 0,
// content: " MomentListItem ",
// likeCount: 42,
// isLike: false,
// commentCount: 5,
// publishTime: Int(Date().timeIntervalSince1970 * 1000),
// worldId: 1,
// status: 1,
// playCount: nil,
// dynamicResList: [
// MomentsPicture(id: 1, resUrl: "https://picsum.photos/300/300", format: "jpg", width: 300, height: 300, resDuration: nil),
// MomentsPicture(id: 2, resUrl: "https://picsum.photos/301/301", format: "jpg", width: 301, height: 301, resDuration: nil)
// ],
// gender: nil,
// squareTop: nil,
// topicTop: nil,
// newUser: nil,
// defUser: nil,
// scene: nil,
// userVipInfoVO: nil,
// headwearPic: nil,
// headwearEffect: nil,
// headwearType: nil,
// headwearName: nil,
// headwearId: nil,
// experLevelPic: nil,
// charmLevelPic: nil,
// isCustomWord: nil,
// labelList: nil
// )
//
// MomentListItem(
// moment: testMoment,
// onImageTap: { images, index in
// print(": \(index), \(images.count)")
// }
// )
// .padding()
// .background(Color.black)
//}