feat: 新增动态点赞与删除功能

- 在APIEndpoints中新增动态点赞和删除端点。
- 实现LikeDynamicRequest和DeleteDynamicRequest结构体,支持动态点赞和删除请求。
- 在DetailFeature中添加点赞和删除动态的逻辑,提升用户交互体验。
- 更新FeedListFeature以支持动态详情视图的展示,增强用户体验。
- 新增DetailView以展示动态详情,包含点赞和删除功能。
This commit is contained in:
edwinQQQ
2025-07-28 11:23:34 +08:00
parent a37d7c6eb8
commit de2f05f545
16 changed files with 826 additions and 1197 deletions

View File

@@ -25,6 +25,8 @@ enum APIEndpoint: String, CaseIterable {
case getUserInfo = "/user/get" //
case getMyDynamic = "/dynamic/getMyDynamic"
case updateUser = "/user/v2/update" //
case dynamicLike = "/dynamic/like" // /
case deleteDynamic = "/dynamic/delete" //
// Web
case userAgreement = "/modules/rule/protocol.html"

View File

@@ -281,3 +281,101 @@ struct GetMyDynamicRequest: APIRequestProtocol {
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}
// MARK: - API
///
struct LikeDynamicResponse: Codable, Equatable, Sendable {
let code: Int
let message: String
let data: LikeDynamicData?
let timestamp: Int?
}
///
struct LikeDynamicData: Codable, Equatable, Sendable {
let success: Bool?
let likeCount: Int?
}
///
struct LikeDynamicRequest: APIRequestProtocol {
typealias Response = LikeDynamicResponse
let endpoint: String = APIEndpoint.dynamicLike.path
let method: HTTPMethod = .POST
let dynamicId: Int
let uid: Int
let status: Int // 0: , 1:
let likedUid: Int
let worldId: Int
init(dynamicId: Int, uid: Int, status: Int, likedUid: Int, worldId: Int) {
self.dynamicId = dynamicId
self.uid = uid
self.status = status
self.likedUid = likedUid
self.worldId = worldId
}
var queryParameters: [String: String]? { nil }
var bodyParameters: [String: Any]? {
return [
"dynamicId": dynamicId,
"uid": uid,
"status": status,
"likedUid": likedUid,
"worldId": worldId
]
}
var includeBaseParameters: Bool { true }
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}
// MARK: - API
///
struct DeleteDynamicResponse: Codable, Equatable, Sendable {
let code: Int
let message: String
let data: DeleteDynamicData?
let timestamp: Int?
}
///
struct DeleteDynamicData: Codable, Equatable, Sendable {
let success: Bool?
}
///
struct DeleteDynamicRequest: APIRequestProtocol {
typealias Response = DeleteDynamicResponse
let endpoint: String = APIEndpoint.deleteDynamic.path
let method: HTTPMethod = .POST
let dynamicId: Int
let uid: Int
init(dynamicId: Int, uid: Int) {
self.dynamicId = dynamicId
self.uid = uid
}
var queryParameters: [String: String]? { nil }
var bodyParameters: [String: Any]? {
return [
"dynamicId": dynamicId,
"uid": uid
]
}
var includeBaseParameters: Bool { true }
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}

View File

@@ -0,0 +1,145 @@
import Foundation
import ComposableArchitecture
@Reducer
struct DetailFeature {
@Dependency(\.apiService) var apiService
@ObservableState
struct State: Equatable {
var moment: MomentsInfo
var isLikeLoading = false
var isDeleteLoading = false
var showImagePreview = false
var selectedImageIndex = 0
var selectedImages: [String] = []
init(moment: MomentsInfo) {
self.moment = moment
}
}
enum Action: Equatable {
case onAppear
case likeDynamic(Int, Int, Int, Int) // dynamicId, uid, likedUid, worldId
case likeResponse(TaskResult<LikeDynamicResponse>)
case deleteDynamic
case deleteResponse(TaskResult<DeleteDynamicResponse>)
case showImagePreview([String], Int)
case hideImagePreview
case imagePreviewDismissed
case onLikeSuccess(Int, Bool) // dynamicId, newLikeState
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
return .none
case let .likeDynamic(dynamicId, uid, likedUid, worldId):
state.isLikeLoading = true
let status = state.moment.isLike ? 0 : 1
let request = LikeDynamicRequest(
dynamicId: dynamicId,
uid: uid,
status: status,
likedUid: likedUid,
worldId: worldId
)
return .run { send in
let result = await TaskResult {
try await apiService.request(request)
}
await send(.likeResponse(result))
}
case let .likeResponse(.success(response)):
state.isLikeLoading = false
//
return .send(.onLikeSuccess(state.moment.dynamicId, !state.moment.isLike))
case let .onLikeSuccess(dynamicId, newLikeState):
//
// MomentsInfoisLikeletmoment
let updatedMoment = MomentsInfo(
dynamicId: state.moment.dynamicId,
uid: state.moment.uid,
nick: state.moment.nick,
avatar: state.moment.avatar,
type: state.moment.type,
content: state.moment.content,
likeCount: state.moment.likeCount,
isLike: newLikeState,
commentCount: state.moment.commentCount,
publishTime: state.moment.publishTime,
worldId: state.moment.worldId,
status: state.moment.status,
playCount: state.moment.playCount,
dynamicResList: state.moment.dynamicResList,
gender: state.moment.gender,
squareTop: state.moment.squareTop,
topicTop: state.moment.topicTop,
newUser: state.moment.newUser,
defUser: state.moment.defUser,
scene: state.moment.scene,
userVipInfoVO: state.moment.userVipInfoVO,
headwearPic: state.moment.headwearPic,
headwearEffect: state.moment.headwearEffect,
headwearType: state.moment.headwearType,
headwearName: state.moment.headwearName,
headwearId: state.moment.headwearId,
experLevelPic: state.moment.experLevelPic,
charmLevelPic: state.moment.charmLevelPic,
isCustomWord: state.moment.isCustomWord,
labelList: state.moment.labelList
)
state.moment = updatedMoment
return .none
case let .likeResponse(.failure(error)):
state.isLikeLoading = false
//
return .none
case .deleteDynamic:
state.isDeleteLoading = true
let request = DeleteDynamicRequest(dynamicId: state.moment.dynamicId, uid: state.moment.uid)
return .run { send in
let result = await TaskResult {
try await apiService.request(request)
}
await send(.deleteResponse(result))
}
case let .deleteResponse(.success(response)):
state.isDeleteLoading = false
//
return .none
case let .deleteResponse(.failure(error)):
state.isDeleteLoading = false
//
return .none
case let .showImagePreview(images, index):
state.selectedImages = images
state.selectedImageIndex = index
state.showImagePreview = true
return .none
case .hideImagePreview:
state.showImagePreview = false
return .none
case .imagePreviewDismissed:
state.showImagePreview = false
return .none
}
}
}
}

View File

@@ -1,111 +0,0 @@
import Foundation
import ComposableArchitecture
@Reducer
struct FeedFeature {
@ObservableState
struct State: Equatable {
var moments: [MomentsInfo] = []
var isLoading = false
var isRefreshing = false
var hasMoreData = true
var error: String?
var nextDynamicId: Int = 0
// CreateFeedView
var createFeedState = CreateFeedFeature.State()
}
enum Action: Equatable {
case onAppear
case refresh
case loadLatestMoments
case loadMoreMoments
case momentsResponse(TaskResult<MomentsLatestResponse>)
case clearError
case retryLoad
// CreateFeedView Action
case createFeedCompleted
case createFeedDismissed
// CreateFeedFeature action
case createFeed(CreateFeedFeature.Action)
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Scope(state: \.createFeedState, action: \.createFeed) {
CreateFeedFeature()
}
Reduce { state, action in
switch action {
case .onAppear:
guard state.moments.isEmpty && !state.isLoading else { return .none }
return .send(.loadLatestMoments)
case .refresh:
guard !state.isRefreshing else { return .none }
state.isRefreshing = true
state.error = nil
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
return .run { send in
await send(.momentsResponse(TaskResult { try await apiService.request(request) }))
}
case .loadLatestMoments:
guard !state.isLoading else { return .none }
state.isLoading = true
state.error = nil
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
return .run { send in
await send(.momentsResponse(TaskResult { try await apiService.request(request) }))
}
case .loadMoreMoments:
guard !state.isLoading && state.hasMoreData else { return .none }
state.isLoading = true
state.error = nil
let request = LatestDynamicsRequest(dynamicId: state.nextDynamicId == 0 ? "" : String(state.nextDynamicId), pageSize: 20, types: [.text, .picture])
return .run { send in
await send(.momentsResponse(TaskResult { try await apiService.request(request) }))
}
case let .momentsResponse(.success(response)):
state.isLoading = false
state.isRefreshing = false
guard response.code == 200, let data = response.data else {
let errorMsg = response.message.isEmpty ? "获取动态失败" : response.message
state.error = errorMsg
return .none
}
let isRefresh = state.nextDynamicId == 0 || state.isRefreshing
if isRefresh {
state.moments = data.dynamicList
} else {
state.moments.append(contentsOf: data.dynamicList)
}
state.nextDynamicId = data.nextDynamicId
state.hasMoreData = !data.dynamicList.isEmpty
return .none
case let .momentsResponse(.failure(error)):
state.isLoading = false
state.isRefreshing = false
state.error = error.localizedDescription
return .none
case .clearError:
state.error = nil
return .none
case .retryLoad:
if state.moments.isEmpty {
return .send(.loadLatestMoments)
} else {
return .send(.loadMoreMoments)
}
case .createFeedCompleted:
return .send(.refresh)
case .createFeedDismissed:
return .none
case .createFeed(.dismissView):
return .send(.createFeedDismissed)
case .createFeed:
return .none
}
}
}
}

View File

@@ -18,6 +18,9 @@ struct FeedListFeature {
var currentPage: Int = 1
var hasMore: Bool = true
var isLoadingMore: Bool = false
// DetailView
var showDetail: Bool = false
var selectedMoment: MomentsInfo?
}
enum Action: Equatable {
@@ -31,6 +34,9 @@ struct FeedListFeature {
//
case fetchFeeds
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
// DetailViewAction
case showDetail(MomentsInfo)
case detailDismissed
// Action
}
@@ -129,6 +135,14 @@ struct FeedListFeature {
case .testButtonTapped:
debugInfoSync("[LOG] FeedListFeature testButtonTapped")
return .none
case let .showDetail(moment):
state.selectedMoment = moment
state.showDetail = true
return .none
case .detailDismissed:
state.showDetail = false
state.selectedMoment = nil
return .none
}
}
}

View File

@@ -1,92 +0,0 @@
import Foundation
import ComposableArchitecture
struct HomeFeature: Reducer {
enum Route: Equatable {
case createFeed
}
struct State: Equatable {
var isInitialized = false
var userInfo: UserInfo?
var accountModel: AccountModel?
var error: String?
var feedState = FeedFeature.State()
var meDynamic = MeDynamicFeature.State(uid: 0)
var isLoggedOut = false
var route: Route? = nil
}
@CasePathable
enum Action: Equatable {
case onAppear
case loadUserInfo
case userInfoLoaded(UserInfo?)
case loadAccountModel
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
case feed(FeedFeature.Action)
case meDynamic(MeDynamicFeature.Action)
case logoutCompleted
case showCreateFeed
case createFeedDismissed
}
var body: some ReducerOf<Self> {
Scope(state: \.feedState, action: \.feed) {
FeedFeature()
}
Scope(state: \.meDynamic, action: \.meDynamic) {
MeDynamicFeature()
}
Reduce { state, action in
switch action {
case .onAppear:
guard !state.isInitialized else { return .none }
state.isInitialized = true
return .concatenate(
.send(.loadUserInfo),
.send(.loadAccountModel)
)
case .loadUserInfo:
return .run { send in
let userInfo = await UserInfoManager.getUserInfo()
await send(.userInfoLoaded(userInfo))
}
case let .userInfoLoaded(userInfo):
state.userInfo = userInfo
state.meDynamic.uid = userInfo?.uid ?? 0
return .none
case .loadAccountModel:
return .run { send in
let accountModel = await UserInfoManager.getAccountModel()
await send(.accountModelLoaded(accountModel))
}
case let .accountModelLoaded(accountModel):
state.accountModel = accountModel
return .none
case .logoutTapped:
return .send(.logout)
case .logout:
return .run { send in
await UserInfoManager.clearAllAuthenticationData()
await send(.logoutCompleted)
}
case .logoutCompleted:
state.isLoggedOut = true
return .none
case .feed:
return .none
case .meDynamic:
return .none
case .showCreateFeed:
state.route = .createFeed
return .none
case .createFeedDismissed:
state.route = nil
return .none
}
}
}
}

View File

@@ -11,8 +11,6 @@ struct LoginFeature {
var error: String?
var idLoginState = IDLoginFeature.State()
var emailLoginState = EMailLoginFeature.State() //
// HomeFeature
var homeState = HomeFeature.State()
// Account Model Ticket
var accountModel: AccountModel?
@@ -54,7 +52,6 @@ struct LoginFeature {
case idLogin(IDLoginFeature.Action)
case emailLogin(EMailLoginFeature.Action) // action
// HomeFeature action
case home(HomeFeature.Action)
// Ticket actions
case requestTicket(accessToken: String)
case ticketResponse(TaskResult<TicketResponse>)
@@ -72,10 +69,6 @@ struct LoginFeature {
Scope(state: \.emailLoginState, action: \.emailLogin) {
EMailLoginFeature()
}
// HomeFeature
Scope(state: \.homeState, action: \.home) {
HomeFeature()
}
Reduce { state, action in
switch action {
@@ -241,8 +234,6 @@ struct LoginFeature {
case .emailLogin:
// EmailLoginfeature
return .none
case .home(_):
return .none
}
}
}

View File

@@ -129,4 +129,7 @@
"appSetting.checkUpdates" = "Check for Updates";
"appSetting.logout" = "Log Out";
"appSetting.aboutUs" = "About Us";
"appSetting.logoutAccount" = "Log out of account";
"appSetting.logoutAccount" = "Log out of account";
// MARK: - Detail
"detail.title" = "Enjoy your life";

View File

@@ -126,3 +126,6 @@
"appSetting.logout" = "退出登录";
"appSetting.aboutUs" = "关于我们";
"appSetting.logoutAccount" = "退出账户";
// MARK: - Detail
"detail.title" = "享受你的生活";

View File

@@ -9,12 +9,21 @@ struct OptimizedDynamicCardView: View {
let currentIndex: Int
//
let onImageTap: (_ images: [String], _ index: Int) -> Void
//
let onLikeTap: (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void
// loading
let isLikeLoading: Bool
//
let isDetailMode: Bool
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void) {
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void, onLikeTap: @escaping (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void, isLikeLoading: Bool = false, isDetailMode: Bool = false) {
self.moment = moment
self.allMoments = allMoments
self.currentIndex = currentIndex
self.onImageTap = onImageTap
self.onLikeTap = onLikeTap
self.isLikeLoading = isLikeLoading
self.isDetailMode = isDetailMode
}
public var body: some View {
@@ -27,6 +36,7 @@ struct OptimizedDynamicCardView: View {
.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: 12) {
//
@@ -88,21 +98,36 @@ struct OptimizedDynamicCardView: View {
//
HStack(spacing: 20) {
// Like
Button(action: {}) {
Button(action: {
onLikeTap(moment.dynamicId, moment.uid, moment.uid, moment.worldId)
}) {
HStack(spacing: 4) {
Image(systemName: moment.isLike ? "heart.fill" : "heart")
.font(.system(size: 16))
if isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: moment.isLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
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))
}
.disabled(isLikeLoading)
Spacer()
}
.padding(.top, 8)
}
.padding(16)
}
.onTapGesture {
//
if !isDetailMode {
//
}
}
.onAppear {
preloadNearbyImages()
}

121
yana/Views/DetailView.swift Normal file
View File

@@ -0,0 +1,121 @@
import SwiftUI
import ComposableArchitecture
struct DetailView: View {
@State var store: StoreOf<DetailFeature>
// @Environment(\.dismiss) private var dismiss
let onLikeSuccess: ((Int, Bool) -> Void)?
init(store: StoreOf<DetailFeature>, onLikeSuccess: ((Int, Bool) -> Void)? = nil) {
self.store = store
self.onLikeSuccess = onLikeSuccess
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
// 使OptimizedDynamicCardView
OptimizedDynamicCardView(
moment: store.moment,
allMoments: [store.moment], //
currentIndex: 0,
onImageTap: { images, index in
store.send(.showImagePreview(images, index))
},
onLikeTap: { dynamicId, uid, likedUid, worldId in
store.send(.likeDynamic(dynamicId, uid, likedUid, worldId))
},
isLikeLoading: store.isLikeLoading,
isDetailMode: true //
)
.padding(.horizontal, 16)
.padding(.top, 16)
}
}
.navigationTitle(NSLocalizedString("detail.title", comment: "Detail page title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
// uiduid
if isCurrentUserDynamic {
Button(action: {
store.send(.deleteDynamic)
}) {
if store.isDeleteLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .red))
.scaleEffect(0.8)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
}
}
.disabled(store.isDeleteLoading)
}
}
}
.onAppear {
store.send(.onAppear)
}
// .onReceive(store.publisher(for: \.moment)) { moment in
// //
// }
.fullScreenCover(isPresented: Binding(
get: { store.showImagePreview },
set: { _ in store.send(.hideImagePreview) }
)) {
ImagePreviewPager(
images: store.selectedImages,
currentIndex: Binding(
get: { store.selectedImageIndex },
set: { newIndex in
store.send(.showImagePreview(store.selectedImages, newIndex))
}
),
onClose: {
store.send(.imagePreviewDismissed)
}
)
}
}
//
private var isCurrentUserDynamic: Bool {
// false
// TODO: ID
return false
}
}
//#Preview {
// DetailView(
// store: Store(
// initialState: DetailFeature.State(
// moment: MomentsInfo(
// dynamicId: 1,
// uid: 123,
// nick: "Test User",
// avatar: "https://example.com/avatar.jpg",
// type: 1,
// content: "This is a test dynamic content",
// publishTime: Int(Date().timeIntervalSince1970 * 1000),
// likeCount: 10,
// isLike: false,
// worldId: 1,
// dynamicResList: [
// MomentsPicture(
// id: 1,
// resUrl: "https://example.com/image1.jpg",
// format: "jpg",
// width: 800,
// height: 600,
// resDuration: nil
// )
// ]
// )
// )
// ) {
// DetailFeature()
// }
// )
//}

View File

@@ -1,6 +1,196 @@
import SwiftUI
import ComposableArchitecture
// MARK: - BackgroundView
struct BackgroundView: View {
var body: some View {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
}
}
// MARK: - TopBarView
struct TopBarView: View {
let onEditTapped: () -> Void
var body: some View {
ZStack {
HStack {
Spacer(minLength: 0)
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .center)
Spacer(minLength: 0)
}
HStack {
Spacer(minLength: 0)
Button(action: onEditTapped) {
Image("add icon")
.resizable()
.frame(width: 40, height: 40)
}
}
}
.padding(.horizontal, 20)
}
}
// MARK: - LoadingView
private struct LoadingView: View {
var body: some View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
}
}
// MARK: - ErrorView
struct ErrorView: View {
let error: String
var body: some View {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.top, 20)
}
}
// MARK: - EmptyView
struct EmptyView: View {
var body: some View {
Text(NSLocalizedString("feedList.empty", comment: "暂无动态"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 20)
}
}
// MARK: - MomentCardView
struct MomentCardView: View {
let moment: MomentsInfo
let allMoments: [MomentsInfo]
let index: Int
let onImageTap: ([String], Int) -> Void
let onTap: () -> Void
let onLoadMore: () -> Void
let isLastItem: Bool
let hasMore: Bool
let isLoadingMore: Bool
var body: some View {
VStack(spacing: 16) {
OptimizedDynamicCardView(
moment: moment,
allMoments: allMoments,
currentIndex: index,
onImageTap: onImageTap,
onLikeTap: { _, _,_,_ in }
)
.onTapGesture {
onTap()
}
//
if isLastItem && hasMore && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
onLoadMore()
}
}
}
}
}
// MARK: - MomentsListView
struct MomentsListView: View {
let moments: [MomentsInfo]
let hasMore: Bool
let isLoadingMore: Bool
let onImageTap: ([String], Int) -> Void
let onMomentTap: (MomentsInfo) -> Void
let onLoadMore: () -> Void
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(Array(moments.enumerated()), id: \.element.dynamicId) { index, moment in
MomentCardView(
moment: moment,
allMoments: moments,
index: index,
onImageTap: onImageTap,
onTap: {
onMomentTap(moment)
},
onLoadMore: onLoadMore,
isLastItem: index == moments.count - 1,
hasMore: hasMore,
isLoadingMore: isLoadingMore
)
}
//
if isLoadingMore {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 8)
}
//
Color.clear.frame(height: 120)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 20)
}
.refreshable {
onLoadMore()
}
}
}
// MARK: - FeedListContentView
struct FeedListContentView: View {
let store: StoreOf<FeedListFeature>
@Binding var previewItem: PreviewItem?
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
if viewStore.isLoading {
LoadingView()
} else if let error = viewStore.error {
ErrorView(error: error)
} else if viewStore.moments.isEmpty {
EmptyView()
} else {
MomentsListView(
moments: viewStore.moments,
hasMore: viewStore.hasMore,
isLoadingMore: viewStore.isLoadingMore,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
},
onMomentTap: { moment in
viewStore.send(.showDetail(moment))
},
onLoadMore: {
viewStore.send(.loadMore)
}
)
}
}
}
}
struct FeedListView: View {
let store: StoreOf<FeedListFeature>
//
@@ -10,137 +200,86 @@ struct FeedListView: View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
VStack(alignment: .center, spacing: 0) {
//
ZStack {
HStack {
Spacer(minLength: 0)
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .center)
Spacer(minLength: 0)
}
HStack {
Spacer(minLength: 0)
Button(action: {
viewStore.send(.editFeedButtonTapped)
}) {
Image("add icon")
.resizable()
.frame(width: 40, height: 40)
}
}
}
ZStack {
//
BackgroundView()
.padding(.horizontal, 20)
//
Image("Volume")
.frame(width: 56, height: 41)
.padding(.top, 16)
Text(NSLocalizedString("feedList.slogan", comment: "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(.leading)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.bottom, 30)
//
if viewStore.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
} else if let error = viewStore.error {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.top, 20)
} else if viewStore.moments.isEmpty {
Text(NSLocalizedString("feedList.empty", comment: "暂无动态"))
VStack(alignment: .center, spacing: 0) {
//
TopBarView {
viewStore.send(.editFeedButtonTapped)
}
//
Image("Volume")
.frame(width: 56, height: 41)
.padding(.top, 16)
Text(NSLocalizedString("feedList.slogan", comment: "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))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 20)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 16) {
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: viewStore.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
}
)
//
if index == viewStore.moments.count - 1, viewStore.hasMore, !viewStore.isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
viewStore.send(.loadMore)
}
}
}
//
if viewStore.isLoadingMore {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 8)
}
//
Color.clear.frame(height: 120)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 20)
}
}
.refreshable {
viewStore.send(.reload)
}
.multilineTextAlignment(.leading)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.bottom, 30)
//
FeedListContentView(
store: store,
previewItem: $previewItem
)
Spacer()
}
Spacer()
.frame(maxWidth: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, alignment: .top)
}
}
.onAppear {
viewStore.send(.onAppear)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("reloadFeedList"))) { _ in
viewStore.send(.reload)
}
.sheet(isPresented: viewStore.binding(
get: \.isEditFeedPresented,
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
)) {
EditFeedView(
onDismiss: {
viewStore.send(.editFeedDismissed)
},
store: Store(
initialState: EditFeedFeature.State()
) {
EditFeedFeature()
.onAppear {
viewStore.send(.onAppear)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("reloadFeedList"))) { _ in
viewStore.send(.reload)
}
.sheet(isPresented: viewStore.binding(
get: \.isEditFeedPresented,
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
)) {
EditFeedView(
onDismiss: {
viewStore.send(.editFeedDismissed)
},
store: Store(
initialState: EditFeedFeature.State()
) {
EditFeedFeature()
}
)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
previewItem = nil
}
}
// DetailView
.navigationDestination(isPresented: viewStore.binding(
get: \.showDetail,
send: { isPresented in
if !isPresented {
return .detailDismissed
}
return .detailDismissed
}
)) {
if let selectedMoment = viewStore.selectedMoment {
DetailView(
store: Store(
initialState: DetailFeature.State(moment: selectedMoment)
) {
DetailFeature()
}
)
}
)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
previewItem = nil
}
}
}
}
}

View File

@@ -1,636 +0,0 @@
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()
// }
// )
//}

View File

@@ -1,88 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct HomeView: View {
let store: StoreOf<HomeFeature>
let onLogout: () -> Void
@ObservedObject private var localizationManager = LocalizationManager.shared
@State private var selectedTab: Tab = .feed
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
// 使 "bg" -
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
// -
ZStack {
switch selectedTab {
case .feed:
FeedView(
store: store.scope(
state: \.feedState,
action: \.feed
),
onShowCreateFeed: {
store.send(.showCreateFeed)
}
)
.transition(.opacity)
case .me:
Spacer()
// MeView(
// meDynamicStore: store.scope(
// state: \.meDynamic,
// action: \.meDynamic
// ),
// onLogout: onLogout
// )
// .transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// -
VStack {
Spacer()
BottomTabView(selectedTab: $selectedTab)
}
.padding(.bottom, geometry.safeAreaInsets.bottom + 100)
}
}
.onAppear {
store.send(.onAppear)
}
.navigationDestination(isPresented: Binding(
get: { store.withState(\.route) == .createFeed },
set: { isPresented in
if !isPresented {
store.send(.createFeedDismissed)
}
}
)) {
CreateFeedView(
store: store.scope(
state: \.feedState.createFeedState,
action: \.feed.createFeed
)
)
}
}
}
}
//#Preview {
// HomeView(
// store: Store(
// initialState: HomeFeature.State()
// ) {
// HomeFeature()
// }, onLogout: {}
// )
//}

View File

@@ -154,7 +154,6 @@ struct LoginView: View {
.navigationBarHidden(true)
}
}
// HomeView navigationDestination
}
.sheet(isPresented: $showLanguageSettings) {
WithPerceptionTracking {

View File

@@ -7,145 +7,161 @@ struct MeView: View {
@State private var previewItem: PreviewItem? = nil
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
VStack {
HStack {
Spacer()
WithViewStore(self.store, observe: { $0 }) { viewStore in
Button(action: {
viewStore.send(.settingButtonTapped)
}) {
Image(systemName: "gearshape")
.font(.system(size: 33, weight: .medium))
.foregroundColor(.white)
}
.padding(.trailing, 16)
.padding(.top, 8)
}
}
Spacer()
}
VStack(spacing: 16) {
//
WithViewStore(self.store, observe: { $0 }) { viewStore in
if viewStore.isLoadingUserInfo {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(height: 130)
} else if let error = viewStore.userInfoError {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.frame(height: 130)
} else if let userInfo = viewStore.userInfo {
VStack(spacing: 8) {
if let avatarUrl = userInfo.avatar, !avatarUrl.isEmpty {
AsyncImage(url: URL(string: avatarUrl)) { image in
image.resizable().aspectRatio(contentMode: .fill).clipShape(Circle())
} placeholder: {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
}
.frame(width: 90, height: 90)
} else {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
.frame(width: 90, height: 90)
}
Text(userInfo.nick ?? "用户昵称")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
Text("ID: \(userInfo.uid ?? 0)")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.top, 0)
.frame(height: 130)
} else {
Spacer().frame(height: 130)
}
}
//
WithViewStore(self.store, observe: { $0 }) { viewStore in
if viewStore.isLoadingMoments && viewStore.moments.isEmpty {
ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewStore.momentsError {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.yellow)
Text(error)
.foregroundColor(.red)
Button("重试") {
viewStore.send(.onAppear)
}
}
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewStore.moments.isEmpty {
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("暂无动态")
.foregroundColor(.white.opacity(0.8))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 12) {
ForEach(viewStore.moments.indices, id: \ .self) { index in
let moment = viewStore.moments[index]
OptimizedDynamicCardView(
moment: moment,
allMoments: viewStore.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
}
)
.padding(.horizontal, 12)
}
if viewStore.hasMore {
ProgressView()
.onAppear {
viewStore.send(.loadMore)
}
}
//
Color.clear.frame(height: 120)
}
.padding(.top, 8)
.clipped()
.ignoresSafeArea(.all)
//
VStack {
HStack {
Spacer()
Button(action: {
viewStore.send(.settingButtonTapped)
}) {
Image(systemName: "gearshape")
.font(.system(size: 33, weight: .medium))
.foregroundColor(.white)
}
.padding(.trailing, 16)
.padding(.top, 8)
}
.refreshable {
viewStore.send(.refresh)
}
Spacer()
}
//
VStack(spacing: 16) {
//
userInfoSection(viewStore: viewStore)
//
momentsSection(viewStore: viewStore)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.top, 8)
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.top, 8)
}
}
.onAppear {
ViewStore(self.store, observe: { $0 }).send(.onAppear)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
previewItem = nil
.onAppear {
viewStore.send(.onAppear)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
previewItem = nil
}
}
}
}
}
// MARK: -
@ViewBuilder
private func userInfoSection(viewStore: ViewStoreOf<MeFeature>) -> some View {
if viewStore.isLoadingUserInfo {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(height: 130)
} else if let error = viewStore.userInfoError {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.frame(height: 130)
} else if let userInfo = viewStore.userInfo {
VStack(spacing: 8) {
if let avatarUrl = userInfo.avatar, !avatarUrl.isEmpty {
AsyncImage(url: URL(string: avatarUrl)) { image in
image.resizable().aspectRatio(contentMode: .fill).clipShape(Circle())
} placeholder: {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
}
.frame(width: 90, height: 90)
} else {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
.frame(width: 90, height: 90)
}
Text(userInfo.nick ?? "用户昵称")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
Text("ID: \(userInfo.uid ?? 0)")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.top, 0)
.frame(height: 130)
} else {
Spacer().frame(height: 130)
}
}
// MARK: -
@ViewBuilder
private func momentsSection(viewStore: ViewStoreOf<MeFeature>) -> some View {
if viewStore.isLoadingMoments && viewStore.moments.isEmpty {
ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewStore.momentsError {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.yellow)
Text(error)
.foregroundColor(.red)
Button("重试") {
viewStore.send(.onAppear)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewStore.moments.isEmpty {
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("暂无动态")
.foregroundColor(.white.opacity(0.8))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 12) {
ForEach(Array(viewStore.moments.enumerated()), id: \.element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: viewStore.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
},
onLikeTap: { _, _, _, _ in
//
}
)
.padding(.horizontal, 12)
}
if viewStore.hasMore {
ProgressView()
.onAppear {
viewStore.send(.loadMore)
}
}
//
Color.clear.frame(height: 120)
}
.padding(.top, 8)
}
}
.refreshable {
viewStore.send(.refresh)
}
}
}
}