Files
e-party-iOS/yana/MVVM/View/MomentListItem.swift
edwinQQQ 6b575dab27 feat: 实现MomentListItem点赞功能及状态管理
- 在MomentListItem中新增点赞功能,用户点击按钮可触发点赞请求。
- 使用MVVM+Combine架构管理点赞状态,确保UI与状态同步。
- 添加加载状态和错误处理,提升用户体验和交互反馈。
- 更新相关视图以支持新的点赞逻辑,优化代码可读性和维护性。
2025-08-07 11:50:30 +08:00

399 lines
15 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 //
//
@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; }
) {
self.moment = moment
self.onImageTap = onImageTap
//
self._localIsLike = State(initialValue: moment.isLike)
self._localLikeCount = State(initialValue: moment.likeCount)
}
var body: some View {
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 {
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)
.padding(.leading, 40 + 8) // +
Spacer()
}
.padding(.top, 8)
}
.padding(16)
}
}
// 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)
//}