feat: 更新动态相关数据模型及视图组件

- 在DynamicsModels.swift中为动态响应结构和列表数据添加Sendable协议,提升并发安全性。
- 在FeedListFeature.swift中实现动态内容的加载逻辑,集成API请求以获取最新动态。
- 在FeedListView.swift中新增动态内容列表展示,优化用户交互体验。
- 在MeView.swift中添加设置按钮,支持弹出设置视图。
- 在SettingView.swift中新增COS上传测试功能,允许用户测试图片上传至腾讯云COS。
- 在OptimizedDynamicCardView.swift中实现优化的动态卡片组件,提升动态展示效果。
This commit is contained in:
edwinQQQ
2025-07-22 17:17:21 +08:00
parent 6c363ea884
commit c8ff40cac1
9 changed files with 1007 additions and 520 deletions

View File

@@ -4,7 +4,7 @@ import ComposableArchitecture
// MARK: - // MARK: -
/// ///
struct MomentsLatestResponse: Codable, Equatable { struct MomentsLatestResponse: Codable, Equatable, Sendable {
let code: Int let code: Int
let message: String let message: String
let data: MomentsListData? let data: MomentsListData?
@@ -12,13 +12,13 @@ struct MomentsLatestResponse: Codable, Equatable {
} }
/// ///
struct MomentsListData: Codable, Equatable { struct MomentsListData: Codable, Equatable, Sendable {
let dynamicList: [MomentsInfo] let dynamicList: [MomentsInfo]
let nextDynamicId: Int let nextDynamicId: Int
} }
/// ///
struct MomentsInfo: Codable, Equatable { public struct MomentsInfo: Codable, Equatable, Sendable {
let dynamicId: Int let dynamicId: Int
let uid: Int let uid: Int
let nick: String let nick: String
@@ -66,7 +66,7 @@ struct MomentsInfo: Codable, Equatable {
} }
/// ///
struct MomentsPicture: Codable, Equatable { struct MomentsPicture: Codable, Equatable, Sendable {
let id: Int let id: Int
let resUrl: String let resUrl: String
let format: String let format: String
@@ -76,7 +76,7 @@ struct MomentsPicture: Codable, Equatable {
} }
/// VIP - /// VIP -
struct UserVipInfo: Codable, Equatable { struct UserVipInfo: Codable, Equatable, Sendable {
let vipLevel: Int? let vipLevel: Int?
let vipName: String? let vipName: String?
let vipIcon: String? let vipIcon: String?

View File

@@ -1,12 +1,16 @@
import Foundation import Foundation
import ComposableArchitecture import ComposableArchitecture
struct FeedListFeature: Reducer { @Reducer
struct FeedListFeature {
@Dependency(\.apiService) var apiService
struct State: Equatable { struct State: Equatable {
var feeds: [Feed] = [] // feed var feeds: [Feed] = [] // feed
var isLoading: Bool = false var isLoading: Bool = false
var error: String? = nil var error: String? = nil
var isEditFeedPresented: Bool = false // EditFeedView var isEditFeedPresented: Bool = false // EditFeedView
//
var moments: [MomentsInfo] = []
} }
enum Action: Equatable { enum Action: Equatable {
@@ -15,13 +19,41 @@ struct FeedListFeature: Reducer {
case loadMore case loadMore
case editFeedButtonTapped // add case editFeedButtonTapped // add
case editFeedDismissed // case editFeedDismissed //
//
case fetchFeeds
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
// Action // Action
} }
func reduce(into state: inout State, action: Action) -> Effect<Action> { func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action { switch action {
case .onAppear: case .onAppear:
// // feed
return .send(.fetchFeeds)
case .fetchFeeds:
state.isLoading = true
state.error = nil
// API
return .run { [apiService] send in
await send(.fetchFeedsResponse(TaskResult {
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
return try await apiService.request(request)
}))
}
case let .fetchFeedsResponse(.success(response)):
state.isLoading = false
if let list = response.data?.dynamicList {
state.moments = list
state.error = nil
} else {
state.moments = []
state.error = response.message
}
return .none
case let .fetchFeedsResponse(.failure(error)):
state.isLoading = false
state.moments = []
state.error = error.localizedDescription
return .none return .none
case .reload: case .reload:
// //

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import ComposableArchitecture import ComposableArchitecture
import QCloudCOSXML
// MARK: - COS // MARK: - COS
@@ -15,6 +16,26 @@ class COSManager: ObservableObject {
private init() {} private init() {}
//
private static var isCOSInitialized = false
//
private func ensureCOSInitialized(tokenData: TcTokenData) {
guard !Self.isCOSInitialized else { return }
let configuration = QCloudServiceConfiguration()
let endpoint = QCloudCOSXMLEndPoint()
endpoint.regionName = tokenData.region
endpoint.useHTTPS = true
if tokenData.accelerate {
endpoint.suffix = "cos.accelerate.myqcloud.com"
}
configuration.endpoint = endpoint
QCloudCOSXMLService.registerDefaultCOSXML(with: configuration)
QCloudCOSTransferMangerService.registerDefaultCOSTransferManger(with: configuration)
Self.isCOSInitialized = true
debugInfoSync("✅ COS服务已初始化region: \(tokenData.region)")
}
// MARK: - Token // MARK: - Token
/// Token /// Token
@@ -102,13 +123,83 @@ class COSManager: ObservableObject {
/// Token /// Token
func getTokenStatus() -> String { func getTokenStatus() -> String {
if let cached = cachedToken, let expiration = tokenExpirationDate { if let _ = cachedToken, let expiration = tokenExpirationDate {
let isExpired = Date() >= expiration let isExpired = Date() >= expiration
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)" return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
} else { } else {
return "Token 状态: 未缓存" return "Token 状态: 未缓存"
} }
} }
// MARK: -
/// COS
/// - Parameters:
/// - imageData:
/// - apiService: API
/// - Returns: nil
func uploadImage(_ imageData: Data, apiService: any APIServiceProtocol & Sendable) async -> String? {
guard let tokenData = await getToken(apiService: apiService) else {
debugInfoSync("❌ 无法获取 COS Token")
return nil
}
// COS
ensureCOSInitialized(tokenData: tokenData)
// COS
let credential = QCloudCredential()
credential.secretID = tokenData.secretId
// secretKey
let rawSecretKey = tokenData.secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
debugInfoSync("secretKey原始内容: [\(rawSecretKey)]")
credential.secretKey = rawSecretKey
credential.token = tokenData.sessionToken
credential.startDate = tokenData.startDate
credential.expirationDate = tokenData.expirationDate
let request = QCloudCOSXMLUploadObjectRequest<AnyObject>()
request.bucket = tokenData.bucket
request.regionName = tokenData.region
request.credential = credential
// key
let fileExtension = "jpg" // JPG
let key = "images/\(UUID().uuidString).\(fileExtension)"
request.object = key
request.body = imageData as AnyObject
//
request.sendProcessBlock = { (bytesSent, totalBytesSent,
totalBytesExpectedToSend) in
debugInfoSync("\(bytesSent), \(totalBytesSent), \(totalBytesExpectedToSend)")
// bytesSent
// totalBytesSent
// totalBytesExpectedToSend
};
//
if tokenData.accelerate {
request.enableQuic = true
// endpoint "cos.accelerate.myqcloud.com"
}
// 使 async/await
return await withCheckedContinuation { continuation in
request.setFinish { result, error in
if let error = error {
debugInfoSync("❌ 图片上传失败: \(error.localizedDescription)")
continuation.resume(returning: " ?????????? ")
} else {
//
let domain = tokenData.customDomain.isEmpty ? "\(tokenData.bucket).cos.\(tokenData.region).myqcloud.com" : tokenData.customDomain
let cloudURL = "https://\(domain)/\(key)"
debugInfoSync("✅ 图片上传成功: \(cloudURL)")
continuation.resume(returning: cloudURL)
}
}
QCloudCOSTransferMangerService.defaultCOSTransferManager().uploadObject(request)
}
}
} }
// MARK: - // MARK: -
@@ -134,4 +225,4 @@ extension COSManager {
debugInfoSync("✅ 腾讯云 COS Token 测试完成\n") debugInfoSync("✅ 腾讯云 COS Token 测试完成\n")
#endif #endif
} }
} }

View File

@@ -0,0 +1,240 @@
import SwiftUI
import ComposableArchitecture
import Foundation
// MARK: -
struct OptimizedDynamicCardView: View {
let moment: MomentsInfo
let allMoments: [MomentsInfo]
let currentIndex: Int
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int) {
self.moment = moment
self.allMoments = allMoments
self.currentIndex = currentIndex
}
public var body: some View {
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] = []
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]
init(images: [MomentsPicture]) {
self.images = images
}
public 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()
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:
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
default:
return 340
}
}
}
// MARK: -
struct SquareImageView: View {
let image: MomentsPicture
let size: CGFloat
init(image: MomentsPicture, size: CGFloat) {
self.image = image
self.size = size
}
public 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)
}
}

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import ComposableArchitecture import ComposableArchitecture
//import OptimizedDynamicCardView //
struct FeedListView: View { struct FeedListView: View {
let store: StoreOf<FeedListFeature> let store: StoreOf<FeedListFeature>
@@ -46,6 +47,35 @@ struct FeedListView: View {
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30) .padding(.horizontal, 30)
.padding(.bottom, 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: "暂无动态"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 20)
} else {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
}
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 20)
}
}
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, alignment: .top) .frame(maxWidth: .infinity, alignment: .top)

View File

@@ -172,460 +172,460 @@ struct FeedView: View {
} }
// MARK: - // MARK: -
struct OptimizedDynamicCardView: View { //struct OptimizedDynamicCardView: View {
let moment: MomentsInfo // let moment: MomentsInfo
let allMoments: [MomentsInfo] // let allMoments: [MomentsInfo]
let currentIndex: Int // let currentIndex: Int
//
var body: some View { // var body: some View {
WithPerceptionTracking{ // WithPerceptionTracking{
VStack(alignment: .leading, spacing: 12) { // VStack(alignment: .leading, spacing: 12) {
// // //
HStack { // HStack {
// 使 // // 使
CachedAsyncImage(url: moment.avatar) { image in // CachedAsyncImage(url: moment.avatar) { image in
image // image
.resizable() // .resizable()
.aspectRatio(contentMode: .fill) // .aspectRatio(contentMode: .fill)
} placeholder: { // } placeholder: {
Circle() // Circle()
.fill(Color.gray.opacity(0.3)) // .fill(Color.gray.opacity(0.3))
.overlay( // .overlay(
Text(String(moment.nick.prefix(1))) // Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium)) // .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) // .foregroundColor(.white)
) // )
} // }
.frame(width: 40, height: 40) // .frame(width: 40, height: 40)
.clipShape(Circle()) // .clipShape(Circle())
//
VStack(alignment: .leading, spacing: 2) { // VStack(alignment: .leading, spacing: 2) {
Text(moment.nick) // Text(moment.nick)
.font(.system(size: 16, weight: .medium)) // .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) // .foregroundColor(.white)
//
Text(formatTime(moment.publishTime)) // Text(formatTime(moment.publishTime))
.font(.system(size: 12)) // .font(.system(size: 12))
.foregroundColor(.white.opacity(0.6)) // .foregroundColor(.white.opacity(0.6))
} // }
//
Spacer() // Spacer()
//
// VIP // // VIP
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel { // if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
Text("VIP\(vipLevel)") // Text("VIP\(vipLevel)")
.font(.system(size: 10, weight: .bold)) // .font(.system(size: 10, weight: .bold))
.foregroundColor(.yellow) // .foregroundColor(.yellow)
.padding(.horizontal, 6) // .padding(.horizontal, 6)
.padding(.vertical, 2) // .padding(.vertical, 2)
.background(Color.yellow.opacity(0.2)) // .background(Color.yellow.opacity(0.2))
.cornerRadius(4) // .cornerRadius(4)
} // }
} // }
//
// // //
if !moment.content.isEmpty { // if !moment.content.isEmpty {
Text(moment.content) // Text(moment.content)
.font(.system(size: 14)) // .font(.system(size: 14))
.foregroundColor(.white.opacity(0.9)) // .foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading) // .multilineTextAlignment(.leading)
} // }
//
// // //
if let images = moment.dynamicResList, !images.isEmpty { // if let images = moment.dynamicResList, !images.isEmpty {
OptimizedImageGrid(images: images) // OptimizedImageGrid(images: images)
} // }
//
// // //
HStack(spacing: 20) { // HStack(spacing: 20) {
Button(action: {}) { // Button(action: {}) {
HStack(spacing: 4) { // HStack(spacing: 4) {
Image(systemName: "message") // Image(systemName: "message")
.font(.system(size: 16)) // .font(.system(size: 16))
Text("\(moment.commentCount)") // Text("\(moment.commentCount)")
.font(.system(size: 14)) // .font(.system(size: 14))
} // }
.foregroundColor(.white.opacity(0.8)) // .foregroundColor(.white.opacity(0.8))
} // }
//
Button(action: {}) { // Button(action: {}) {
HStack(spacing: 4) { // HStack(spacing: 4) {
Image(systemName: moment.isLike ? "heart.fill" : "heart") // Image(systemName: moment.isLike ? "heart.fill" : "heart")
.font(.system(size: 16)) // .font(.system(size: 16))
Text("\(moment.likeCount)") // Text("\(moment.likeCount)")
.font(.system(size: 14)) // .font(.system(size: 14))
} // }
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8)) // .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
} // }
//
Spacer() // Spacer()
} // }
.padding(.top, 8) // .padding(.top, 8)
} // }
} // }
.padding(16) // .padding(16)
.background( // .background(
Color.white.opacity(0.1) // Color.white.opacity(0.1)
.cornerRadius(12) // .cornerRadius(12)
) // )
.onAppear { // .onAppear {
// // //
preloadNearbyImages() // preloadNearbyImages()
} // }
} // }
//
private func formatTime(_ timestamp: Int) -> String { // private func formatTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0) // let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter() // let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN") // formatter.locale = Locale(identifier: "zh_CN")
//
let now = Date() // let now = Date()
let interval = now.timeIntervalSince(date) // let interval = now.timeIntervalSince(date)
//
if interval < 60 { // if interval < 60 {
return "" // return ""
} else if interval < 3600 { // } else if interval < 3600 {
return "\(Int(interval / 60))分钟" // return "\(Int(interval / 60))"
} else if interval < 86400 { // } else if interval < 86400 {
return "\(Int(interval / 3600))小时" // return "\(Int(interval / 3600))"
} else { // } else {
formatter.dateFormat = "MM-dd HH:mm" // formatter.dateFormat = "MM-dd HH:mm"
return formatter.string(from: date) // return formatter.string(from: date)
} // }
} // }
//
private func preloadNearbyImages() { // private func preloadNearbyImages() {
var urlsToPreload: [String] = [] // var urlsToPreload: [String] = []
//
// 2 // // 2
let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2) // let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
//
for index in preloadRange { // for index in preloadRange {
let moment = allMoments[index] // let moment = allMoments[index]
//
// // //
urlsToPreload.append(moment.avatar) // urlsToPreload.append(moment.avatar)
//
// // //
if let images = moment.dynamicResList { // if let images = moment.dynamicResList {
urlsToPreload.append(contentsOf: images.map { $0.resUrl }) // urlsToPreload.append(contentsOf: images.map { $0.resUrl })
} // }
} // }
//
// // //
ImageCacheManager.shared.preloadImages(urls: urlsToPreload) // ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
} // }
} //}
// MARK: - // MARK: -
struct OptimizedImageGrid: View { //struct OptimizedImageGrid: View {
let images: [MomentsPicture] // let images: [MomentsPicture]
//
var body: some View { // var body: some View {
GeometryReader { geometry in // GeometryReader { geometry in
let availableWidth = max(geometry.size.width, 1) // 0 // let availableWidth = max(geometry.size.width, 1) // 0
let spacing: CGFloat = 8 // let spacing: CGFloat = 8
//
// availableWidth // // availableWidth
if availableWidth < 10 { // if availableWidth < 10 {
Color.clear.frame(height: 1) // Color.clear.frame(height: 1)
} else { // } else {
switch images.count { // switch images.count {
case 1: // case 1:
// // //
let imageSize: CGFloat = min(availableWidth * 0.6, 200) // let imageSize: CGFloat = min(availableWidth * 0.6, 200)
HStack { // HStack {
Spacer() // Spacer()
SquareImageView(image: images[0], size: imageSize) // SquareImageView(image: images[0], size: imageSize)
Spacer() // Spacer()
} // }
case 2: // case 2:
// // //
let imageSize: CGFloat = (availableWidth - spacing) / 2 // let imageSize: CGFloat = (availableWidth - spacing) / 2
HStack(spacing: spacing) { // HStack(spacing: spacing) {
SquareImageView(image: images[0], size: imageSize) // SquareImageView(image: images[0], size: imageSize)
SquareImageView(image: images[1], size: imageSize) // SquareImageView(image: images[1], size: imageSize)
} // }
case 3: // case 3:
// // //
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3 // let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
HStack(spacing: spacing) { // HStack(spacing: spacing) {
ForEach(images.prefix(3), id: \.id) { image in // ForEach(images.prefix(3), id: \.id) { image in
SquareImageView(image: image, size: imageSize) // SquareImageView(image: image, size: imageSize)
} // }
} // }
default: // default:
// 9 // // 9
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3 // let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3) // let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
LazyVGrid(columns: columns, spacing: spacing) { // LazyVGrid(columns: columns, spacing: spacing) {
ForEach(images.prefix(9), id: \.id) { image in // ForEach(images.prefix(9), id: \.id) { image in
SquareImageView(image: image, size: imageSize) // SquareImageView(image: image, size: imageSize)
} // }
} // }
} // }
} // }
} // }
.frame(height: calculateGridHeight()) // .frame(height: calculateGridHeight())
} // }
//
private func calculateGridHeight() -> CGFloat { // private func calculateGridHeight() -> CGFloat {
switch images.count { // switch images.count {
case 1: // case 1:
return 200 // // return 200 //
case 2: // case 2:
return 120 // // return 120 //
case 3: // case 3:
return 100 // // return 100 //
case 4...6: // case 4...6:
return 216 // 2 ( * 2 + ) // return 216 // 2 ( * 2 + )
default: // default:
return 340 // 3 ( * 3 + + ) // return 340 // 3 ( * 3 + + )
} // }
} // }
} //}
// MARK: - // MARK: -
struct SquareImageView: View { //struct SquareImageView: View {
let image: MomentsPicture // let image: MomentsPicture
let size: CGFloat // let size: CGFloat
//
var body: some View { // var body: some View {
let safeSize = size.isFinite && size > 0 ? size : 100 // // let safeSize = size.isFinite && size > 0 ? size : 100 //
CachedAsyncImage(url: image.resUrl) { imageView in // CachedAsyncImage(url: image.resUrl) { imageView in
imageView // imageView
.resizable() // .resizable()
.aspectRatio(contentMode: .fill) // .aspectRatio(contentMode: .fill)
} placeholder: { // } placeholder: {
Rectangle() // Rectangle()
.fill(Color.gray.opacity(0.3)) // .fill(Color.gray.opacity(0.3))
.overlay( // .overlay(
ProgressView() // ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6))) // .progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
.scaleEffect(0.8) // .scaleEffect(0.8)
) // )
} // }
.frame(width: safeSize, height: safeSize) // .frame(width: safeSize, height: safeSize)
.clipped() // .clipped()
.cornerRadius(8) // .cornerRadius(8)
} // }
} //}
// MARK: - // MARK: -
struct RealDynamicCardView: View { //struct RealDynamicCardView: View {
let moment: MomentsInfo // let moment: MomentsInfo
//
var body: some View { // var body: some View {
VStack(alignment: .leading, spacing: 12) { // VStack(alignment: .leading, spacing: 12) {
// // //
HStack { // HStack {
AsyncImage(url: URL(string: moment.avatar)) { image in // AsyncImage(url: URL(string: moment.avatar)) { image in
image // image
.resizable() // .resizable()
.aspectRatio(contentMode: .fill) // .aspectRatio(contentMode: .fill)
} placeholder: { // } placeholder: {
Circle() // Circle()
.fill(Color.gray.opacity(0.3)) // .fill(Color.gray.opacity(0.3))
.overlay( // .overlay(
Text(String(moment.nick.prefix(1))) // Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium)) // .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) // .foregroundColor(.white)
) // )
} // }
.frame(width: 40, height: 40) // .frame(width: 40, height: 40)
.clipShape(Circle()) // .clipShape(Circle())
//
VStack(alignment: .leading, spacing: 2) { // VStack(alignment: .leading, spacing: 2) {
Text(moment.nick) // Text(moment.nick)
.font(.system(size: 16, weight: .medium)) // .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) // .foregroundColor(.white)
//
Text(formatTime(moment.publishTime)) // Text(formatTime(moment.publishTime))
.font(.system(size: 12)) // .font(.system(size: 12))
.foregroundColor(.white.opacity(0.6)) // .foregroundColor(.white.opacity(0.6))
} // }
//
Spacer() // Spacer()
//
// VIP // // VIP
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel { // if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
Text("VIP\(vipLevel)") // Text("VIP\(vipLevel)")
.font(.system(size: 10, weight: .bold)) // .font(.system(size: 10, weight: .bold))
.foregroundColor(.yellow) // .foregroundColor(.yellow)
.padding(.horizontal, 6) // .padding(.horizontal, 6)
.padding(.vertical, 2) // .padding(.vertical, 2)
.background(Color.yellow.opacity(0.2)) // .background(Color.yellow.opacity(0.2))
.cornerRadius(4) // .cornerRadius(4)
} // }
} // }
//
// // //
if !moment.content.isEmpty { // if !moment.content.isEmpty {
Text(moment.content) // Text(moment.content)
.font(.system(size: 14)) // .font(.system(size: 14))
.foregroundColor(.white.opacity(0.9)) // .foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading) // .multilineTextAlignment(.leading)
} // }
//
// // //
if let images = moment.dynamicResList, !images.isEmpty { // if let images = moment.dynamicResList, !images.isEmpty {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) { // LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
ForEach(images.prefix(9), id: \.id) { image in // ForEach(images.prefix(9), id: \.id) { image in
AsyncImage(url: URL(string: image.resUrl)) { imageView in // AsyncImage(url: URL(string: image.resUrl)) { imageView in
imageView // imageView
.resizable() // .resizable()
.aspectRatio(contentMode: .fill) // .aspectRatio(contentMode: .fill)
} placeholder: { // } placeholder: {
Rectangle() // Rectangle()
.fill(Color.gray.opacity(0.3)) // .fill(Color.gray.opacity(0.3))
.overlay( // .overlay(
ProgressView() // ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6))) // .progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
) // )
} // }
.frame(height: 100) // .frame(height: 100)
.clipped() // .clipped()
.cornerRadius(8) // .cornerRadius(8)
} // }
} // }
} // }
//
// // //
HStack(spacing: 20) { // HStack(spacing: 20) {
Button(action: {}) { // Button(action: {}) {
HStack(spacing: 4) { // HStack(spacing: 4) {
Image(systemName: "message") // Image(systemName: "message")
.font(.system(size: 16)) // .font(.system(size: 16))
Text("\(moment.commentCount)") // Text("\(moment.commentCount)")
.font(.system(size: 14)) // .font(.system(size: 14))
} // }
.foregroundColor(.white.opacity(0.8)) // .foregroundColor(.white.opacity(0.8))
} // }
//
Button(action: {}) { // Button(action: {}) {
HStack(spacing: 4) { // HStack(spacing: 4) {
Image(systemName: moment.isLike ? "heart.fill" : "heart") // Image(systemName: moment.isLike ? "heart.fill" : "heart")
.font(.system(size: 16)) // .font(.system(size: 16))
Text("\(moment.likeCount)") // Text("\(moment.likeCount)")
.font(.system(size: 14)) // .font(.system(size: 14))
} // }
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8)) // .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
} // }
//
Spacer() // Spacer()
} // }
.padding(.top, 8) // .padding(.top, 8)
} // }
.padding(16) // .padding(16)
.background( // .background(
Color.white.opacity(0.1) // Color.white.opacity(0.1)
.cornerRadius(12) // .cornerRadius(12)
) // )
} // }
//
private func formatTime(_ timestamp: Int) -> String { // private func formatTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0) // let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter() // let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN") // formatter.locale = Locale(identifier: "zh_CN")
//
let now = Date() // let now = Date()
let interval = now.timeIntervalSince(date) // let interval = now.timeIntervalSince(date)
//
if interval < 60 { // if interval < 60 {
return "" // return ""
} else if interval < 3600 { // } else if interval < 3600 {
return "\(Int(interval / 60))分钟" // return "\(Int(interval / 60))"
} else if interval < 86400 { // } else if interval < 86400 {
return "\(Int(interval / 3600))小时" // return "\(Int(interval / 3600))"
} else { // } else {
formatter.dateFormat = "MM-dd HH:mm" // formatter.dateFormat = "MM-dd HH:mm"
return formatter.string(from: date) // return formatter.string(from: date)
} // }
} // }
} //}
// MARK: - // MARK: -
struct DynamicCardView: View { //struct DynamicCardView: View {
let index: Int // let index: Int
//
var body: some View { // var body: some View {
VStack(alignment: .leading, spacing: 12) { // VStack(alignment: .leading, spacing: 12) {
// // //
HStack { // HStack {
Circle() // Circle()
.fill(Color.gray.opacity(0.3)) // .fill(Color.gray.opacity(0.3))
.frame(width: 40, height: 40) // .frame(width: 40, height: 40)
.overlay( // .overlay(
Text("U\(index + 1)") // Text("U\(index + 1)")
.font(.system(size: 16, weight: .medium)) // .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) // .foregroundColor(.white)
) // )
//
VStack(alignment: .leading, spacing: 2) { // VStack(alignment: .leading, spacing: 2) {
Text("用户\(index + 1)") // Text("\(index + 1)")
.font(.system(size: 16, weight: .medium)) // .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) // .foregroundColor(.white)
//
Text("2小时") // Text("2")
.font(.system(size: 12)) // .font(.system(size: 12))
.foregroundColor(.white.opacity(0.6)) // .foregroundColor(.white.opacity(0.6))
} // }
//
Spacer() // Spacer()
} // }
//
// // //
Text("今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻") // Text("")
.font(.system(size: 14)) // .font(.system(size: 14))
.foregroundColor(.white.opacity(0.9)) // .foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading) // .multilineTextAlignment(.leading)
//
// // //
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) { // LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
ForEach(0..<3) { imageIndex in // ForEach(0..<3) { imageIndex in
Rectangle() // Rectangle()
.fill(Color.gray.opacity(0.3)) // .fill(Color.gray.opacity(0.3))
.aspectRatio(1, contentMode: .fit) // .aspectRatio(1, contentMode: .fit)
.overlay( // .overlay(
Image(systemName: "photo") // Image(systemName: "photo")
.foregroundColor(.white.opacity(0.6)) // .foregroundColor(.white.opacity(0.6))
) // )
} // }
} // }
//
// // //
HStack(spacing: 20) { // HStack(spacing: 20) {
Button(action: {}) { // Button(action: {}) {
HStack(spacing: 4) { // HStack(spacing: 4) {
Image(systemName: "message") // Image(systemName: "message")
.font(.system(size: 16)) // .font(.system(size: 16))
Text("354") // Text("354")
.font(.system(size: 14)) // .font(.system(size: 14))
} // }
.foregroundColor(.white.opacity(0.8)) // .foregroundColor(.white.opacity(0.8))
} // }
//
Button(action: {}) { // Button(action: {}) {
HStack(spacing: 4) { // HStack(spacing: 4) {
Image(systemName: "heart") // Image(systemName: "heart")
.font(.system(size: 16)) // .font(.system(size: 16))
Text("354") // Text("354")
.font(.system(size: 14)) // .font(.system(size: 14))
} // }
.foregroundColor(.white.opacity(0.8)) // .foregroundColor(.white.opacity(0.8))
} // }
//
Spacer() // Spacer()
} // }
.padding(.top, 8) // .padding(.top, 8)
} // }
.padding(16) // .padding(16)
.background( // .background(
Color.white.opacity(0.1) // Color.white.opacity(0.1)
.cornerRadius(12) // .cornerRadius(12)
) // )
} // }
} //}
//#Preview { //#Preview {
// FeedView( // FeedView(

View File

@@ -27,6 +27,7 @@ struct MainView: View {
)) ))
.transition(.opacity) .transition(.opacity)
case .other: case .other:
MeView(onLogout: {}) // MeView(onLogout: {}) //
.transition(.opacity) .transition(.opacity)
} }

View File

@@ -1,8 +1,10 @@
import SwiftUI import SwiftUI
import ComposableArchitecture
struct MeView: View { struct MeView: View {
@State private var showLogoutConfirmation = false @State private var showLogoutConfirmation = false
let onLogout: () -> Void // let onLogout: () -> Void //
@State private var showSetting = false // SettingView
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@@ -15,6 +17,12 @@ struct MeView: View {
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
.foregroundColor(.white) .foregroundColor(.white)
Spacer() Spacer()
Button(action: { showSetting = true }) {
Image(systemName: "gearshape")
.font(.system(size: 22, weight: .regular))
.foregroundColor(.white)
}
.padding(.trailing, 8)
} }
.padding(.top, geometry.safeAreaInsets.top + 20) .padding(.top, geometry.safeAreaInsets.top + 20)
@@ -74,6 +82,7 @@ struct MeView: View {
// - // -
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100) Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
} }
.padding(.top, 100)
} }
} }
.ignoresSafeArea(.container, edges: .top) .ignoresSafeArea(.container, edges: .top)
@@ -85,6 +94,10 @@ struct MeView: View {
} message: { } message: {
Text("确定要退出登录吗?") Text("确定要退出登录吗?")
} }
.sheet(isPresented: $showSetting) {
// storestore
SettingView(store: Store(initialState: SettingFeature.State()) { SettingFeature() })
}
} }
// MARK: - 退 // MARK: - 退
@@ -132,6 +145,6 @@ struct MenuItemView: View {
} }
} }
#Preview { //#Preview {
MeView(onLogout: {}) // MeView(onLogout: {})
} //}

View File

@@ -6,73 +6,75 @@ struct SettingView: View {
@ObservedObject private var localizationManager = LocalizationManager.shared @ObservedObject private var localizationManager = LocalizationManager.shared
var body: some View { var body: some View {
WithPerceptionTracking { NavigationStack {
GeometryReader { geometry in WithPerceptionTracking {
ZStack { GeometryReader { geometry in
// - 使"bg" ZStack {
Image("bg") // - 使"bg"
.resizable() Image("bg")
.aspectRatio(contentMode: .fill) .resizable()
.ignoresSafeArea(.all) .aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
// Navigation Bar
HStack {
//
Button(action: {
store.send(.dismissTapped)
}) {
Image(systemName: "chevron.left")
.font(.title2)
.foregroundColor(.white)
}
.padding(.leading, 16)
Spacer()
//
Text(NSLocalizedString("setting.title", comment: "Settings"))
.font(.custom("PingFang SC-Semibold", size: 16))
.foregroundColor(.white)
Spacer()
//
Color.clear
.frame(width: 44, height: 44)
}
.padding(.top, 8)
.padding(.horizontal)
// VStack(spacing: 0) {
ScrollView { // Navigation Bar
VStack(spacing: 24) { HStack {
UserInfoCardView(userInfo: store.userInfo, accountModel: store.accountModel) //
// .padding() Button(action: {
.padding(.top, 32) store.send(.dismissTapped)
}) {
SettingOptionsView( Image(systemName: "chevron.left")
onLanguageTapped: { .font(.title2)
// TODO: .foregroundColor(.white)
}, }
onAboutTapped: { .padding(.leading, 16)
// TODO:
} Spacer()
)
//
Spacer(minLength: 50) Text(NSLocalizedString("setting.title", comment: "Settings"))
.font(.custom("PingFang SC-Semibold", size: 16))
LogoutButtonView { .foregroundColor(.white)
store.send(.logoutTapped)
Spacer()
//
Color.clear
.frame(width: 44, height: 44)
}
.padding(.top, 8)
.padding(.horizontal)
//
ScrollView {
VStack(spacing: 24) {
UserInfoCardView(userInfo: store.userInfo, accountModel: store.accountModel)
// .padding()
.padding(.top, 32)
SettingOptionsView(
onLanguageTapped: {
// TODO:
},
onAboutTapped: {
// TODO:
}
)
Spacer(minLength: 50)
LogoutButtonView {
store.send(.logoutTapped)
}
.padding(.bottom, 50)
} }
.padding(.bottom, 50)
} }
} }
} }
} }
} .onAppear {
.onAppear { store.send(.onAppear)
store.send(.onAppear) }
} }
} }
} }
@@ -123,6 +125,61 @@ struct UserInfoCardView: View {
} }
// MARK: - Setting Options View // MARK: - Setting Options View
// Add this new view for testing COS upload
struct TestCOSUploadView: View {
@State private var imageURL: String = "https://img.toto.im/mw600/66b3de17ly1i3mpcw0k7yj20hs0md0tf.jpg.webp"
@State private var uploadResult: String = ""
@State private var isUploading: Bool = false
@Dependency(\.apiService) private var apiService
var body: some View {
VStack(spacing: 20) {
TextField("Enter image URL", text: $imageURL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
Button(action: {
Task {
await uploadImageFromURL()
}
}) {
Text(isUploading ? "Uploading..." : "Upload to COS")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.disabled(isUploading || imageURL.isEmpty)
Text(uploadResult)
.multilineTextAlignment(.center)
.padding()
}
.navigationTitle("Test COS Upload")
}
private func uploadImageFromURL() async {
guard let url = URL(string: imageURL) else {
uploadResult = "Invalid URL"
return
}
isUploading = true
uploadResult = ""
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let cloudURL = await COSManager.shared.uploadImage(data, apiService: apiService) {
uploadResult = "Upload successful! Cloud URL: \(cloudURL)"
} else {
uploadResult = "Upload failed"
}
} catch {
uploadResult = "Download failed: \(error.localizedDescription)"
}
isUploading = false
}
}
// Modify SettingOptionsView to add the test row
struct SettingOptionsView: View { struct SettingOptionsView: View {
let onLanguageTapped: () -> Void let onLanguageTapped: () -> Void
let onAboutTapped: () -> Void let onAboutTapped: () -> Void
@@ -141,6 +198,29 @@ struct SettingOptionsView: View {
action: onAboutTapped action: onAboutTapped
) )
#if DEBUG
NavigationLink(destination: TestCOSUploadView()) {
HStack {
Image(systemName: "cloud.upload")
.foregroundColor(.white.opacity(0.8))
.frame(width: 24)
Text("Test COS Upload")
.foregroundColor(.white)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.white.opacity(0.6))
.font(.caption)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(Color.black.opacity(0.2))
.cornerRadius(12)
}
#endif
HStack { HStack {
Image(systemName: "app.badge") Image(systemName: "app.badge")
.foregroundColor(.white.opacity(0.8)) .foregroundColor(.white.opacity(0.8))
@@ -223,4 +303,4 @@ struct SettingRowView: View {
// SettingFeature() // SettingFeature()
// } // }
// ) // )
//} //}