20 Commits

Author SHA1 Message Date
edwinQQQ
c072a7e73d feat: 移除设置功能并优化主功能状态管理
- 从HomeFeature和MainFeature中移除设置相关状态和逻辑,简化状态管理。
- 更新视图以去除设置页面的展示,提升用户体验。
- 删除SettingFeature及其相关视图,减少冗余代码,增强代码可维护性。
2025-07-24 15:04:39 +08:00
edwinQQQ
6cc4b11e93 feat: 更新AppSettingFeature和MainFeature以支持登出功能
- 在AppSettingFeature中实现登出逻辑,清理认证信息并发送登出事件。
- 在MainFeature中新增登出标志和相应的状态管理,确保用户登出后状态更新。
- 更新MainView以响应登出事件,重命名内部视图为InternalMainView以提高可读性。
- 在SplashView中集成登出处理,确保用户能够顺利返回登录页面。
2025-07-24 14:46:39 +08:00
edwinQQQ
cb325724dc feat: 更新AppSettingFeature以改进用户信息加载逻辑
- 重构用户信息加载逻辑,采用Result类型处理成功与失败的响应,提升错误处理能力。
- 更新状态管理,确保用户信息加载状态与错误信息的准确反映。
- 移除冗余代码,简化用户信息获取流程,增强代码可读性。
2025-07-24 14:03:51 +08:00
edwinQQQ
71c40e465d feat: 更新AppSettingFeature以增强用户设置功能
- 重构AppSettingFeature,采用@Reducer和@ObservableState以优化状态管理。
- 新增用户信息加载逻辑,支持从服务器获取用户信息并更新界面。
- 更新AppSettingView,整合头像、昵称及其他设置项的展示,提升用户体验。
- 增加多语言支持,更新Localizable.strings文件以适应新的设置项。
2025-07-24 11:24:04 +08:00
edwinQQQ
f30026821a feat: 更新MainFeature以优化用户状态管理
- 在MainFeature中增强用户状态管理逻辑,确保仅在用户切换时重置首次加载状态。
- 更新MeFeature的uid处理逻辑,提升用户体验与状态一致性。
2025-07-24 10:20:29 +08:00
edwinQQQ
25fec8a2e6 feat: 增强FeedListFeature和MeFeature的首次加载逻辑
- 在FeedListFeature和MeFeature中新增isFirstLoad状态,确保仅在首次加载时请求数据。
- 更新MainView以简化视图切换逻辑,使用isHidden修饰符控制视图显示。
- 新增View+isHidden扩展,提供视图隐藏功能,提升代码可读性和复用性。
2025-07-24 10:20:12 +08:00
edwinQQQ
3a74547684 feat: 新增应用设置功能及相关视图
- 在MainFeature中集成AppSettingFeature,支持应用设置页面的导航与状态管理。
- 新增AppSettingView以展示用户头像和昵称编辑功能,提升用户体验。
- 更新MainView以支持应用设置页面的展示,增强导航功能。
2025-07-23 20:15:14 +08:00
edwinQQQ
bb49b00a59 feat: 新增导航功能与设置页面支持
- 在MainFeature中新增导航路径和设置页面状态管理,支持页面导航。
- 更新MainView以集成导航功能,添加测试按钮以触发导航。
- 在MeFeature中新增设置按钮点击事件,交由MainFeature处理。
- 增强MeView以支持设置按钮,提升用户体验。
2025-07-23 20:10:37 +08:00
edwinQQQ
772543243f feat: 重构用户动态功能,整合MeFeature并更新MainFeature
- 将MeDynamicFeature重命名为MeFeature,并在MainFeature中进行相应更新。
- 更新MainFeature的状态管理,整合用户动态相关逻辑。
- 新增MeFeature以管理用户信息和动态加载,提升代码结构清晰度。
- 更新MainView和MeView以适应新的MeFeature,优化用户体验。
- 删除冗余的MeDynamicView,简化视图结构,提升代码可维护性。
2025-07-23 19:31:17 +08:00
edwinQQQ
8b09653c4c feat: 更新动态功能,新增我的动态视图及相关状态管理
- 在HomeFeature中添加MeDynamicFeature以管理用户动态状态。
- 在MainFeature中集成MeDynamicFeature,支持动态内容的加载与展示。
- 新增MeDynamicView以展示用户的动态列表,支持下拉刷新和上拉加载更多功能。
- 更新MeView以集成用户动态视图,提升用户体验。
- 在APIEndpoints中新增getMyDynamic端点以支持获取用户动态信息。
- 更新DynamicsModels以适应新的动态数据结构,确保数据解析的准确性。
- 在OptimizedDynamicCardView中优化图片处理逻辑,提升动态展示效果。
- 更新相关视图组件以支持动态内容的展示与交互,增强用户体验。
2025-07-23 19:17:49 +08:00
edwinQQQ
3a68270ca9 feat: 更新README.md以反映项目架构和功能增强
- 在项目简介中添加The Composable Architecture (TCA)架构设计信息。
- 更新技术栈部分,包含支持的开发语言、最低支持版本及主要框架。
- 增加用户认证、云存储集成及自定义UI组件的描述。
- 修改环境要求以支持iOS 16及以上版本。
- 更新开发规范,采用TCA架构模式并支持多语言。
- 添加API测试目标和开发团队信息,提升文档完整性。
2025-07-23 17:57:02 +08:00
edwinQQQ
0fe3b6cb7a feat: 新增用户信息获取功能及相关模型
- 在APIEndpoints.swift中新增getUserInfo端点以支持获取用户信息。
- 在APIModels.swift中实现获取用户信息请求和响应模型,处理用户信息的请求与解析。
- 在UserInfoManager中新增方法以从服务器获取用户信息,并在登录成功后自动获取用户信息。
- 在SettingFeature中新增用户信息刷新状态管理,支持用户信息的刷新操作。
- 在SettingView中集成用户信息刷新按钮,提升用户体验。
- 在SplashFeature中实现自动获取用户信息的逻辑,优化用户登录流程。
- 在yanaAPITests中添加用户信息相关的单元测试,确保功能的正确性。
2025-07-23 11:46:46 +08:00
edwinQQQ
8362142c49 feat: 更新图片预览功能,支持本地与远程图片展示
- 在ImagePreviewPager中引入ImagePreviewSource枚举,支持本地和远程图片的统一处理。
- 优化OptimizedDynamicCardView,新增图片预览状态管理,集成全屏图片预览功能。
- 更新OptimizedImageGrid以支持图片点击事件,触发预览弹窗,提升用户体验。
2025-07-22 19:49:42 +08:00
edwinQQQ
ed3e7100c3 feat: 增强发布动态功能,支持图片上传与进度显示
- 在PublishFeedRequest中新增resList属性,支持上传图片资源信息。
- 在EditFeedFeature中实现图片上传逻辑,处理图片选择与上传进度。
- 更新EditFeedView以显示图片上传进度,提升用户体验。
- 在COSManager中新增UIImage上传方法,优化图片上传流程。
- 在FeedListView中添加通知以刷新动态列表,确保数据同步。
2025-07-22 19:02:48 +08:00
edwinQQQ
fd6e44c6f9 feat: 新增图片预览功能与现代图片选择组件
- 在EditFeedView中集成ImagePreviewPager,支持全屏图片预览。
- 更新ModernImageSelectionGrid,添加图片点击事件以触发预览。
- 移除冗余的PhotosUI导入,优化代码结构。
2025-07-22 18:20:21 +08:00
edwinQQQ
2a02553015 feat: 增强编辑动态功能,支持图片选择与处理
- 在EditFeedFeature中新增图片选择相关状态和动作,支持用户选择和处理最多9张图片。
- 在EditFeedView中集成ModernImageSelectionGrid组件,优化图片选择界面。
- 更新CreateFeedView以支持图片选择功能,提升用户体验。
- 实现图片处理逻辑,确保用户能够方便地添加和删除图片。
2025-07-22 18:06:10 +08:00
edwinQQQ
4eb01bde7c feat: 实现动态内容的分页加载与刷新功能
- 在FeedListFeature中新增分页相关状态管理,支持上拉加载更多和下拉刷新功能,提升用户体验。
- 在FeedListView中实现上拉加载更多的触发逻辑和加载指示器,优化动态内容展示。
2025-07-22 17:43:24 +08:00
edwinQQQ
60b3f824be feat: 增加首次加载标志以优化数据请求逻辑
- 在FeedListFeature中新增isLoaded属性,确保仅在首次加载时请求feed数据,提升性能和用户体验。
2025-07-22 17:26:29 +08:00
edwinQQQ
c8ff40cac1 feat: 更新动态相关数据模型及视图组件
- 在DynamicsModels.swift中为动态响应结构和列表数据添加Sendable协议,提升并发安全性。
- 在FeedListFeature.swift中实现动态内容的加载逻辑,集成API请求以获取最新动态。
- 在FeedListView.swift中新增动态内容列表展示,优化用户交互体验。
- 在MeView.swift中添加设置按钮,支持弹出设置视图。
- 在SettingView.swift中新增COS上传测试功能,允许用户测试图片上传至腾讯云COS。
- 在OptimizedDynamicCardView.swift中实现优化的动态卡片组件,提升动态展示效果。
2025-07-22 17:17:21 +08:00
edwinQQQ
6c363ea884 feat: 添加发布动态功能及相关视图组件
- 在APIEndpoints.swift中新增publishFeed端点以支持发布动态。
- 新增PublishFeedRequest和PublishFeedResponse模型,处理发布请求和响应。
- 在EditFeedFeature中实现动态编辑功能,支持用户输入和发布内容。
- 更新CreateFeedView和EditFeedView以集成新的发布功能,提升用户体验。
- 在Localizable.strings中添加相关文本的本地化支持,确保多语言兼容性。
- 优化FeedListView和FeedView以展示最新动态,增强用户交互体验。
2025-07-22 15:13:32 +08:00
40 changed files with 3815 additions and 1263 deletions

View File

@@ -4,27 +4,28 @@ globs:
alwaysApply: true
---
# CONTEXT
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
# OBJECTIVE
As an expert AI programming assistant, your task is to provide me with clear and readable SwiftUI code. You should:
- Utilize the latest versions of SwiftUI and Swift, being familiar with the newest features and best practices.
## OBJECTIVE
As an expert AI programming assistant, your task is to provide me with clear, readable, and effective code. You should:
- Utilize the latest versions of SwiftUI, Swift(6) and TCA(1.20.2), being familiar with the newest features and best practices.
- Provide careful and accurate answers that are well-founded and thoughtfully considered.
- **Explicitly use the Chain-of-Thought (CoT) method in your reasoning and answers, explaining your thought process step by step.**
- Strictly adhere to my requirements and meticulously complete the tasks.
- Begin by outlining your proposed approach with detailed steps or pseudocode.
- Upon confirming the plan, proceed to write the code.
# STYLE
## STYLE
- Keep answers concise and direct, minimizing unnecessary wording.
- Emphasize code readability over performance optimization.
- Maintain a professional and supportive tone, ensuring clarity of content.
## RESPONSE FORMAT
# AUDIENCE
The target audience is me, a native Chinese developer eager to learn Swift 6 and Xcode 15.9, seeking guidance and advice on utilizing the latest technologies.
# RESPONSE FORMAT
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
- The reply should include:
1. **Step-by-Step Plan**: Describe the implementation process with detailed pseudocode or step-by-step explanations, showcasing your thought process.

View File

@@ -2,37 +2,50 @@
## 项目简介
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能。
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能,并采用 The Composable Architecture (TCA) 架构设计
## 技术栈
- 开发语言Swift
- 最低支持版本iOS 15.6
- 主要框架:
- NIMSDK_LITE网易云信即时通讯 SDK
- **开发语言**Swift (主要)Objective-C (部分组件)
- **最低支持版本**iOS 16
- **架构模式**The Composable Architecture (TCA) - 1.20.2
- **UI 框架**SwiftUI
- **依赖管理**
- CocoaPods
- Swift Package Manager
- **主要框架**
- NIMSDK_LITE网易云信即时通讯 SDK (10.6.1)
- NEChatKit聊天核心组件
- NEChatUIKit会话聊天UI 组件
- NEContactUIKit通讯录 UI 组件
- NELocalConversationUIKit本地会话列表 UI 组件
- Alamofire网络请求框架
- ComposableArchitecture状态管理 (v1.20.2+)
- CasePaths枚举模式匹配
## 项目结构
```
yana/
├── AppDelegate.swift # 应用程序代理
├── yanaApp.swift # SwiftUI 应用入口
├── ContentView.swift # 主视图
├── Managers/ # 管理器类
├── Models/ # 数据模型
├── Configs/ # 配置文件
└── Assets.xcassets/ # 资源文件
├── yana/ # 应用源代码
│ ├── Info.plist
│ ├── yana-Bridging-Header.h # Objective-C 集成桥接头文件
│ ├── AppDelegate.swift # 应用程序代理
│ ├── yanaApp.swift # SwiftUI 应用入口
├── ContentView.swift # 主视图
│ ├── Managers/ # 管理器类
│ ├── Models/ # 数据模型
│ ├── Configs/ # 配置文件
│ ├── APIs/ # API 相关文件
│ └── Assets.xcassets/ # 资源文件
├── yanaAPITests/ # API 测试目标
└── Pods/ # CocoaPods 依赖
```
## 环境要求
- Xcode 13.0 或更高版本
- iOS 15.6 或更高版本
- iOS 16 或更高版本
- CocoaPods 包管理器
## 安装步骤
@@ -49,10 +62,24 @@ yana/
## 主要功能
- 即时通讯
- 会话管理
- 通讯录管理
- 本地会话列表
- **用户认证**
- 邮箱登录流程(带验证码)
- 多种认证方式
- **即时通讯**
- **会话管理**
- **通讯录管理**
- **本地会话列表**
- **云存储集成**
## UI 组件
项目包含多种自定义 UI 组件:
- 自定义登录按钮
- 底部标签导航
- API 调用加载效果
- Web 视图集成
- 图片预览功能
- 屏幕适配工具
## API 使用
@@ -75,21 +102,27 @@ let response = try await apiService.request(request)
- 项目使用 CocoaPods 管理依赖
- 需要配置网易云信相关密钥
- 最低支持 iOS 15.6 版本
- 最低支持 iOS 16 版本
- 仅支持 iPhone 设备(不支持 iPad、Mac Catalyst 或 Vision Pro
## 开发规范
- 遵循 Swift 官方编码规范
- 使用 SwiftUI 构建用户界面
- 采用 MVVM 架构模式
- 采用 TCA 架构模式
- 支持多语言(包含中文本地化)
## 依赖版本
## 测试
- NIMSDK 相关组件版本10.6.1
- Alamofire最新版本
项目包含专门的 API 测试目标 "yanaAPITests",用于对主应用的 API 功能进行单元测试。
## 开发团队
项目由团队 "EKM7RAGNA6" 开发,测试目标的包标识符为 "com.stupidmonkey.yanaAPITests"。
## 构建配置
- 项目使用动态框架
- 支持 iOS 13.0 及以上版本
- 支持 iOS 16 及以上版本
- Swift 版本6.0
- 已配置框架冲突处理脚本

View File

@@ -583,14 +583,16 @@
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKM7RAGNA6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
PRODUCT_BUNDLE_IDENTIFIER = com.junpeiqi.eparty;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
@@ -610,7 +612,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKM7RAGNA6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -21,10 +21,15 @@ enum APIEndpoint: String, CaseIterable {
case emailGetCode = "/email/getCode" //
case latestDynamics = "/dynamic/square/latestDynamics" //
case tcToken = "/tencent/cos/getToken" // COS Token
case publishFeed = "/dynamic/square/publish" //
case getUserInfo = "/user/get" //
case getMyDynamic = "/dynamic/getMyDynamic"
// Web
case userAgreement = "/modules/rule/protocol.html"
case privacyPolicy = "/modules/rule/privacy-wap.html"
var path: String {
return self.rawValue
}

View File

@@ -722,3 +722,124 @@ struct TcTokenData: Codable, Equatable {
}
}
// MARK: - User Info API Management
extension UserInfoManager {
///
/// - Parameters:
/// - uid: IDnil使ID
/// - apiService: API
/// - Returns: nil
static func fetchUserInfoFromServer(
uid: String? = nil,
apiService: APIServiceProtocol
) async -> UserInfo? {
// ID
let targetUid: String
if let uid = uid {
targetUid = uid
} else {
// 使ID
guard let currentUid = await getCurrentUserId() else {
debugErrorSync("❌ 无法获取用户信息:当前用户未登录")
return nil
}
targetUid = currentUid
}
debugInfoSync("👤 开始获取用户信息")
debugInfoSync(" 目标UID: \(targetUid)")
do {
let request = UserInfoHelper.createGetUserInfoRequest(uid: targetUid)
let response = try await apiService.request(request)
if response.isSuccess {
debugInfoSync("✅ 用户信息获取成功")
if let userInfo = response.data {
//
await saveUserInfo(userInfo)
debugInfoSync("💾 用户信息已保存到本地")
return userInfo
} else {
debugErrorSync("❌ 用户信息为空")
return nil
}
} else {
debugErrorSync("❌ 获取用户信息失败: \(response.errorMessage)")
return nil
}
} catch {
debugErrorSync("❌ 获取用户信息异常: \(error.localizedDescription)")
return nil
}
}
///
/// - Parameter apiService: API
/// - Returns:
static func refreshCurrentUserInfo(apiService: APIServiceProtocol) async -> Bool {
guard let currentUid = await getCurrentUserId() else {
debugErrorSync("❌ 无法刷新用户信息:当前用户未登录")
return false
}
debugInfoSync("🔄 开始刷新当前用户信息")
debugInfoSync(" 当前UID: \(currentUid)")
if let userInfo = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) {
debugInfoSync("✅ 用户信息刷新成功")
return true
} else {
debugErrorSync("❌ 用户信息刷新失败")
return false
}
}
///
/// - Parameters:
/// - uid: ID
/// - apiService: API
/// - forceRefresh: false
/// - Returns: nil
static func getUserInfoWithCache(
uid: String,
apiService: APIServiceProtocol,
forceRefresh: Bool = false
) async -> UserInfo? {
//
if !forceRefresh {
if let cachedUserInfo = await getUserInfo() {
debugInfoSync("📱 使用本地缓存的用户信息")
return cachedUserInfo
}
}
//
debugInfoSync("🌐 从服务器获取用户信息")
return await fetchUserInfoFromServer(uid: uid, apiService: apiService)
}
/// APP
/// - Parameter apiService: API
/// - Returns:
static func autoFetchUserInfoOnAppLaunch(apiService: APIServiceProtocol) async -> Bool {
//
let authStatus = await checkAuthenticationStatus()
guard authStatus.canAutoLogin else {
debugInfoSync("🔍 APP启动用户未登录跳过用户信息获取")
return false
}
//
if let cachedUserInfo = await getUserInfo() {
debugInfoSync("📱 APP启动使用现有用户信息缓存")
return true
}
//
debugInfoSync("🔄 APP启动自动获取用户信息")
return await refreshCurrentUserInfo(apiService: apiService)
}
}

View File

@@ -4,7 +4,7 @@ import ComposableArchitecture
// MARK: -
///
struct MomentsLatestResponse: Codable, Equatable {
struct MomentsLatestResponse: Codable, Equatable, Sendable {
let code: Int
let message: String
let data: MomentsListData?
@@ -12,18 +12,17 @@ struct MomentsLatestResponse: Codable, Equatable {
}
///
struct MomentsListData: Codable, Equatable {
struct MomentsListData: Codable, Equatable, Sendable {
let dynamicList: [MomentsInfo]
let nextDynamicId: Int
}
///
struct MomentsInfo: Codable, Equatable {
public struct MomentsInfo: Codable, Equatable, Sendable {
let dynamicId: Int
let uid: Int
let nick: String
let avatar: String
let gender: Int
let type: Int
let content: String
let likeCount: Int
@@ -31,52 +30,47 @@ struct MomentsInfo: Codable, Equatable {
let commentCount: Int
let publishTime: Int
let worldId: Int
let squareTop: Int
let topicTop: Int
let newUser: Bool
let defUser: Int
let status: Int
let scene: String
// data.md
let playCount: Int?
let dynamicResList: [MomentsPicture]?
//
let gender: Int?
let squareTop: Int?
let topicTop: Int?
let newUser: Bool?
let defUser: Int?
let scene: String?
let userVipInfoVO: UserVipInfo?
// -
let headwearPic: String?
let headwearEffect: String?
let headwearType: Int?
let headwearName: String?
let headwearId: Int?
// -
let experLevelPic: String?
let charmLevelPic: String?
//
let isCustomWord: Bool?
let labelList: [String]?
// IntBool
var isSquareTop: Bool { squareTop != 0 }
var isTopicTop: Bool { topicTop != 0 }
//
//
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
var formattedPublishTime: Date {
Date(timeIntervalSince1970: TimeInterval(publishTime) / 1000.0)
}
}
///
struct MomentsPicture: Codable, Equatable {
let id: Int
let resUrl: String
let format: String
let width: Int
let height: Int
struct MomentsPicture: Codable, Equatable, Sendable {
let id: Int?
let resUrl: String?
let format: String?
let width: Int?
let height: Int?
let resDuration: Int? //
}
/// VIP -
struct UserVipInfo: Codable, Equatable {
struct UserVipInfo: Codable, Equatable, Sendable {
let vipLevel: Int?
let vipName: String?
let vipIcon: String?
@@ -158,3 +152,132 @@ struct LatestDynamicsRequest: APIRequestProtocol {
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}
// MARK: - API
///
struct ResListItem: Codable, Equatable {
let resUrl: String
let width: Int
let height: Int
let format: String
}
///
struct PublishFeedRequest: APIRequestProtocol {
typealias Response = PublishFeedResponse
let endpoint: String = APIEndpoint.publishFeed.path
let method: HTTPMethod = .POST
let content: String
let uid: String
let type: String
var pub_sign: String
let resList: [ResListItem]?
var queryParameters: [String: String]? { nil }
var bodyParameters: [String: Any]? {
var params: [String: Any] = [
"content": content,
"uid": uid,
"type": type,
"pub_sign": pub_sign
]
if let resList = resList, !resList.isEmpty {
params["resList"] = resList.map { [
"resUrl": $0.resUrl,
"width": $0.width,
"height": $0.height,
"format": $0.format
] }
}
return params
}
var includeBaseParameters: Bool { true }
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
/// async 线 pub_sign
static func make(content: String, uid: String, type: String = "0", resList: [ResListItem]? = nil) async -> PublishFeedRequest {
let base = await MainActor.run { BaseRequest() }
var mutableBase = base
mutableBase.generateSignature(with: [
"content": content,
"uid": uid,
"type": type
])
return PublishFeedRequest(
content: content,
uid: uid,
type: type,
pub_sign: mutableBase.pubSign,
resList: resList
)
}
///
private init(content: String, uid: String, type: String, pub_sign: String, resList: [ResListItem]?) {
self.content = content
self.uid = uid
self.type = type
self.pub_sign = pub_sign
self.resList = resList
}
}
///
struct PublishFeedResponse: Codable, Equatable {
let code: Int
let message: String
let data: PublishFeedData?
let timestamp: Int?
}
///
struct PublishFeedData: Codable, Equatable {
let dynamicId: Int?
}
// MARK: - API
///
struct MyMomentsResponse: Codable, Equatable, Sendable {
let code: Int
let message: String
let data: [MomentsInfo]?
let timestamp: Int?
}
struct GetMyDynamicRequest: APIRequestProtocol {
typealias Response = MyMomentsResponse
let endpoint: String = APIEndpoint.getMyDynamic.path
let method: HTTPMethod = .POST
let fromUid: Int
let uid: Int
let page: Int
let pageSize: Int
init(fromUid: Int, uid: Int, page: Int = 1, pageSize: Int = 20) {
self.fromUid = fromUid
self.uid = uid
self.page = page
self.pageSize = pageSize
}
var queryParameters: [String: String]? {
[
"fromUid": String(fromUid),
"uid": String(uid),
"page": String(page),
"pageSize": String(pageSize)
]
}
var bodyParameters: [String: Any]? { nil }
var includeBaseParameters: Bool { true }
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}

View File

@@ -146,29 +146,209 @@ struct IDLoginData: Codable, Equatable {
// MARK: - User Info Model
struct UserInfo: Codable, Equatable {
let userId: String?
let username: String?
let nickname: String?
let uid: Int?
let userId: String? //
let nick: String?
let nickname: String? //
let avatar: String?
let email: String?
let phone: String?
let status: String?
let createTime: String?
let updateTime: String?
let region: String?
let regionDesc: String?
let gender: Int?
let birth: Int64?
let userDesc: String?
let userLevelVo: UserLevelVo?
let userVipInfoVO: UserVipInfoVO?
let medalsPic: [MedalsPic]?
let userHeadwear: UserHeadwear?
let privatePhoto: [PrivatePhoto]?
let createTime: Int64?
let phoneAreaCode: String?
let erbanNo: Int?
let isCertified: Bool?
let isBindPhone: Bool?
let isBindApple: Bool?
let isBindPasswd: Bool?
let isBindPaymentPwd: Bool?
let banAccount: Bool?
let visitNum: Int?
let fansNum: Int?
let followNum: Int?
let visitHide: Bool?
let visitListView: Bool?
let newUser: Bool?
let defUser: Int?
let platformRole: Int?
let bindType: Int?
let showLimitCharge: Bool?
let uploadGifAvatarPrice: Int?
let hasRegPacket: Bool?
let hasPrettyErbanNo: Bool?
let hasSuperRole: Bool?
let isRechargeUser: Bool?
let isFirstCharge: Bool?
let fromSayHelloChannel: Bool?
let partitionId: Int?
let useStatus: Int?
let micNickColor: String?
let micCircle: String?
let audioCard: AudioCard?
let userInfoSkillVo: UserInfoSkillVo?
let userInfoCardPic: String?
let iosBubbleUrl: String?
let androidBubbleUrl: String?
let status: String? //
let username: String? //
let email: String? //
let phone: String? //
let updateTime: String? //
enum CodingKeys: String, CodingKey {
case uid
case userId = "user_id"
case username
case nick
case nickname
case avatar
case region
case regionDesc
case gender
case birth
case userDesc
case userLevelVo
case userVipInfoVO
case medalsPic
case userHeadwear
case privatePhoto
case createTime
case phoneAreaCode
case erbanNo
case isCertified
case isBindPhone
case isBindApple
case isBindPasswd
case isBindPaymentPwd
case banAccount
case visitNum
case fansNum
case followNum
case visitHide
case visitListView
case newUser
case defUser
case platformRole
case bindType
case showLimitCharge
case uploadGifAvatarPrice
case hasRegPacket
case hasPrettyErbanNo
case hasSuperRole
case isRechargeUser
case isFirstCharge
case fromSayHelloChannel
case partitionId
case useStatus
case micNickColor
case micCircle
case audioCard
case userInfoSkillVo
case userInfoCardPic
case iosBubbleUrl
case androidBubbleUrl
case status
case username
case email
case phone
case status
case createTime = "create_time"
case updateTime = "update_time"
case updateTime
}
}
// MARK: -
struct UserLevelVo: Codable, Equatable {
let experUrl: String?
let charmLevelSeq: Int?
let experLevelName: String?
let charmLevelName: String?
let charmAmount: Int?
let experLevelGrp: String?
let charmUrl: String?
let experLevelSeq: Int?
let experAmount: Int?
let charmLevelGrp: String?
}
struct UserVipInfoVO: Codable, Equatable {
let vipIcon: String?
let nameplateId: Int?
let vipLogo: String?
let userCardBG: String?
let preventKick: Bool?
let preventTrace: Bool?
let preventFollow: Bool?
let micNickColour: String?
let micCircle: String?
let enterRoomEffects: String?
let medalSeat: Int?
let friendNickColour: String?
let visitHide: Bool?
let visitListView: Bool?
let privateChatLimit: Bool?
let nameplateUrl: String?
let roomPicScreen: Bool?
let uploadGifAvatar: Bool?
let expireTime: Int64?
let enterHide: Bool?
let vipLevel: Int?
let vipName: String?
}
struct MedalsPic: Codable, Equatable {
let picUrl: String?
let mp4Url: String?
}
struct UserHeadwear: Codable, Equatable {
let expireTime: Int64?
let renewPrice: Int?
let uid: Int?
let comeFrom: Int?
let labelType: Int?
let limitDesc: String?
let redirectLink: String?
let headwearId: Int?
let buyTime: Int64?
let pic: String?
let used: Bool?
let price: Int?
let originalPrice: Int?
let type: Int?
let days: Int?
let headwearName: String?
let effect: String?
let expireDays: Int?
let status: Int?
}
struct PrivatePhoto: Codable, Equatable {
let seqNo: Int?
let photoUrl: String?
let createTime: Int64?
let review: Bool?
let pid: Int?
}
struct AudioCard: Codable, Equatable {
let uid: Int?
let status: Int?
}
struct UserInfoSkillVo: Codable, Equatable {
let liveTag: Bool?
let liveSkillVoList: [LiveSkillVo]?
}
struct LiveSkillVo: Codable, Equatable {
// API
}
// MARK: - Login Helper
struct LoginHelper {
@@ -413,3 +593,66 @@ extension LoginHelper {
return EmailLoginRequest(email: encryptedEmail, code: code)
}
}
// MARK: - User Info API Models
///
struct GetUserInfoRequest: APIRequestProtocol {
typealias Response = GetUserInfoResponse
let endpoint = APIEndpoint.getUserInfo.path
let method: HTTPMethod = .GET
let includeBaseParameters = true
let queryParameters: [String: String]?
var bodyParameters: [String: Any]? { nil }
let timeout: TimeInterval = 30.0
let shouldShowLoading: Bool = false // loading
let shouldShowError: Bool = false //
///
/// - Parameter uid: ID
init(uid: String) {
self.queryParameters = [
"uid": uid
]
}
}
///
struct GetUserInfoResponse: Codable, Equatable {
let code: Int?
let message: String?
let timestamp: Int64?
let data: UserInfo?
///
var isSuccess: Bool {
return code == 200
}
///
var errorMessage: String {
return message ?? "获取用户信息失败,请重试"
}
}
// MARK: - User Info Helper
struct UserInfoHelper {
///
/// - Parameter uid: ID
/// - Returns: API
static func createGetUserInfoRequest(uid: String) -> GetUserInfoRequest {
return GetUserInfoRequest(uid: uid)
}
///
/// - Parameter uid: ID
static func debugGetUserInfoRequest(uid: String) {
debugInfoSync("👤 获取用户信息请求调试")
debugInfoSync(" UID: \(uid)")
debugInfoSync(" Endpoint: /user/get")
debugInfoSync(" Method: GET")
debugInfoSync(" Parameters: uid=\(uid)")
}
}

View File

@@ -1,92 +1,330 @@
## 📝 给继任者的详细工作交接说明
亲爱的继任者,我刚刚为这个 **yana iOS 项目**完成了一个完整的图片缓存优化工作。以下是关键信息:
### 🎯 已完成的核心工作
1. **解决了重大性能问题**
- **问题**FeedView 中图片每次滚动都重新加载,用户体验极差
- **原因**AsyncImage 缓存不足没有预加载机制cell 重用时图片丢失
2. **创建了企业级图片缓存系统**
- **文件**`yana/Utils/ImageCacheManager.swift`
- **功能**:三层缓存(内存+磁盘+网络)+ 智能预加载 + 任务去重
3. **优化了 FeedView 架构**
- **文件**`yana/Views/FeedView.swift`
- **改进**:使用 `CachedAsyncImage` 替代 `AsyncImage`,添加预加载机制
### ✅ 技术架构详情
#### **ImageCacheManager 核心特性**
- **内存缓存**NSCache50MB 限制100张图片
- **磁盘缓存**Documents/ImageCache100MB 限制SHA256 文件名
- **预加载**当前位置前后2个动态的所有图片
- **任务去重**:同一图片多次请求共享下载任务
#### **CachedAsyncImage 组件**
- **缓存优先级**:内存 → 磁盘 → 网络
- **异步加载**:不阻塞主线程
- **SwiftUI 兼容**:完全兼容现有 AsyncImage 语法
#### **FeedView 优化**
- **OptimizedDynamicCardView**:使用缓存图片组件
- **OptimizedImageGrid**:优化的图片网格
- **智能预加载**onAppear 时触发相邻内容预加载
### 🔧 重要的技术细节
1. **哈希冲突解决**
- 项目中已有 `String+MD5.swift` 文件
- 使用现有的 `sha256()``md5()` 方法,避免重复声明
2. **兼容性处理**
- iOS 13+:使用 CryptoKit 的 SHA256
- iOS 13以下使用 CommonCrypto 的 MD5
3. **Bridging Header 配置**
- 已添加 `#import <CommonCrypto/CommonCrypto.h>`
### 🚀 性能提升效果
| 优化前 | 优化后 |
|--------|--------|
| ❌ 每次滚动重新加载图片 | ✅ 缓存图片瞬间显示 |
| ❌ 频繁网络请求 | ✅ 大幅减少网络请求 |
| ❌ 用户体验差 | ✅ 流畅滚动体验 |
### 📋 项目上下文回顾
1. **API 功能已完成**
- 动态内容 API 集成完毕DynamicsModels.swift + FeedFeature.swift
- 数据解析问题已解决(类型匹配修复)
- TCA 架构状态管理正常工作
2. **当前状态**
- ✅ 编译成功
- ✅ API 数据正常显示
- ✅ 图片缓存系统就绪
- ✅ 性能优化完成
### 🔍 可能的后续工作
用户可能需要:
1. **功能扩展**:点赞、评论、分享等交互功能
2. **UI 优化**:更丰富的动画效果、主题切换
3. **性能监控**:添加缓存命中率统计、内存使用监控
4. **错误处理**:网络异常时的重试机制优化
### 💡 重要提醒
- **用户是中文开发者**:需要中文回复,使用 Chain-of-Thought 思考过程
- **项目基于 iOS 15.6**:注意兼容性要求
- **TCA 架构**:遵循项目现有的 TCA 模式
- **图片缓存系统**:已经是生产就绪的企业级方案,无需重构
### 🎉 工作成果
这次优化彻底解决了图片重复加载的性能问题,用户现在可以享受流畅的滚动体验。缓存系统设计完善,支持大规模图片内容,为后续功能扩展奠定了坚实基础。
**继任者,你接手的是一个功能完整、性能优秀的动态内容展示系统!** 🚀
祝你工作顺利!
📦 Response Data:
{
"code" : 200,
"message" : "success",
"data" : [
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 267,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753182147000,
"status" : 0,
"content" : "我"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 3024,
"id" : 443,
"width" : 4032,
"resUrl" : "https:\/\/image.molistar.xyz\/images\/C32EB0F8-CBF5-4F4B-8114-C3C7E1AF192F.jpg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 266,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753181890000,
"status" : 0,
"content" : ""
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 828,
"id" : 442,
"width" : 828,
"resUrl" : "https:\/\/image.molistar.xyz\/images\/1E8FE811-1989-4337-BDEE-63554F92A686.jpg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 265,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753181143000,
"status" : 0,
"content" : "大"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 3024,
"id" : 440,
"width" : 4032,
"resUrl" : "https:\/\/https:\/\/image.molistar.xyz\/images\/DF8E655B-2F63-4B34-90B3-13C8A812245C.jpg",
"format" : "jpeg"
},
{
"height" : 1792,
"id" : 441,
"width" : 828,
"resUrl" : "https:\/\/https:\/\/image.molistar.xyz\/images\/D869C761-59CC-4E6B-BB2A-74F87D4A4979.jpg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 264,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753180835000,
"status" : 0,
"content" : "好"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 1792,
"id" : 438,
"width" : 828,
"resUrl" : "https:\/\/image.molistar.xyz\/image\/9d5c8e10eb0d228a26ec4e8d58b41c38.jpeg",
"format" : "jpeg"
},
{
"height" : 1792,
"id" : 439,
"width" : 828,
"resUrl" : "https:\/\/image.molistar.xyz\/image\/9ab8dff9f5ffbb4d65998822dd126794.jpeg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 263,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753180130000,
"status" : 0,
"content" : "猜猜猜"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 262,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753168392000,
"status" : 0,
"content" : "他哥哥哥哥哥哥"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 261,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753168329000,
"status" : 0,
"content" : "一直以为自己是真的"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 260,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753167661000,
"status" : 0,
"content" : "在意那些是自己一"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 259,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753166596000,
"status" : 0,
"content" : "哈哈我觉得这个世界"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 258,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753166592000,
"status" : 0,
"content" : "哈哈我觉得这个世界"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 257,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753166298000,
"status" : 0,
"content" : "哈哈哈哈更"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 256,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753165531000,
"status" : 0,
"content" : "不不不不不"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 255,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753156105000,
"status" : 0,
"content" : "你有什么"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 254,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1752650142000,
"status" : 0,
"content" : "igvigciycoyvcoyvyovoy突袭陶瓷陶瓷陶瓷陶瓷陶瓷陶瓷陶瓷"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 247,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1742801936000,
"status" : 0,
"content" : "vicigiigohvhveerr让你表弟姐姐接你吧多半都不\n\n\n\n代表脯肉吧多半日品牌狠批人品很频频频频噢……在一起的时候就是一次又来了就可以😌我们一起加油呀我要好好学习📑你好开心🥳、在一起的时候就像一只手握在一起\n"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 206,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1726834519000,
"status" : 1,
"content" : "爸爸不会后悔就"
},
{
"isLike" : true,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 500,
"id" : 355,
"width" : 500,
"resUrl" : "https:\/\/image.pekolive.com\/image\/0c091078d01305f3144ab3352a9fe21a.jpeg",
"format" : "jpeg"
},
{
"height" : 328,
"id" : 356,
"width" : 440,
"resUrl" : "https:\/\/image.pekolive.com\/image\/8cdbbab3a0e6df7389f2d2671ee48bc3.jpeg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 1,
"type" : 2,
"dynamicId" : 205,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1726834050000,
"status" : 1,
"content" : "寄件人、一直以为自己是什么地方玩不"
}
],
"timestamp" : 1753256129947
}
=====================================

View File

@@ -2,7 +2,9 @@ import UIKit
//import NIMSDK
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
// isPerceptionCheckingEnabled = false
// UserDefaults Keychain
DataMigrationManager.performStartupMigration()

View File

@@ -0,0 +1,123 @@
import Foundation
import ComposableArchitecture
@Reducer
struct AppSettingFeature {
@ObservableState
struct State: Equatable {
var nickname: String = ""
var avatarURL: String? = nil
var userInfo: UserInfo? = nil
var isLoadingUserInfo: Bool = false
var userInfoError: String? = nil
// WebView
var showUserAgreement: Bool = false
var showPrivacyPolicy: Bool = false
}
enum Action: Equatable {
case onAppear
case editNicknameTapped
case logoutTapped
//
case loadUserInfo
case userInfoResponse(Result<UserInfo, APIError>)
// WebView
case personalInfoPermissionsTapped
case helpTapped
case clearCacheTapped
case checkUpdatesTapped
case aboutUsTapped
// WebView
case userAgreementDismissed
case privacyPolicyDismissed
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
return .send(.loadUserInfo)
case .editNicknameTapped:
//
return .none
case .logoutTapped:
//
return .run { send in
await UserInfoManager.clearAllAuthenticationData()
// FeatureMainFeature
// .noneMainFeatureAppSettingFeature.Action.logoutTapped
}
case .loadUserInfo:
state.isLoadingUserInfo = true
state.userInfoError = nil
return .run { send in
do {
if let userInfo = await UserInfoManager.getUserInfo() {
await send(.userInfoResponse(.success(userInfo)))
} else {
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
}
} catch {
let apiError: APIError
if let error = error as? APIError {
apiError = error
} else {
apiError = APIError.custom(error.localizedDescription)
}
await send(.userInfoResponse(.failure(apiError)))
}
}
case let .userInfoResponse(.success(userInfo)):
state.userInfo = userInfo
state.nickname = userInfo.nick ?? ""
state.avatarURL = userInfo.avatar
state.isLoadingUserInfo = false
return .none
case let .userInfoResponse(.failure(error)):
state.userInfoError = error.localizedDescription
state.isLoadingUserInfo = false
return .none
case .personalInfoPermissionsTapped:
state.showPrivacyPolicy = true
return .none
case .helpTapped:
state.showUserAgreement = true
return .none
case .clearCacheTapped:
//
return .none
case .checkUpdatesTapped:
//
return .none
case .aboutUsTapped:
//
return .none
case .userAgreementDismissed:
state.showUserAgreement = false
return .none
case .privacyPolicyDismissed:
state.showPrivacyPolicy = false
return .none
}
}
}
}

View File

@@ -145,7 +145,7 @@ extension CreateFeedFeature.Action: Equatable {
struct PublishDynamicRequest: APIRequestProtocol {
typealias Response = PublishDynamicResponse
let endpoint: String = "/dynamic/square/publish"
let endpoint: String = APIEndpoint.publishFeed.path
let method: HTTPMethod = .POST
let includeBaseParameters: Bool = true
let queryParameters: [String: String]? = nil

View File

@@ -160,10 +160,20 @@ struct EMailLoginFeature {
case .loginResponse(.success(let accountModel)):
state.isLoading = false
state.loginStep = .completed
// Effect AccountModel
// Effect AccountModel
return .run { _ in
await UserInfoManager.saveAccountModel(accountModel)
// NotificationCenter.default.post(name: .ticketSuccess, object: nil)
//
debugInfoSync("🔄 邮箱登录成功,开始获取用户信息")
if let _ = await UserInfoManager.fetchUserInfoFromServer(
uid: accountModel.uid,
apiService: apiService
) {
debugInfoSync("✅ 用户信息获取成功")
} else {
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
}
}
case .loginResponse(.failure(let error)):

View File

@@ -0,0 +1,214 @@
import Foundation
import ComposableArchitecture
import SwiftUI
import PhotosUI // PhotosUI
@Reducer
struct EditFeedFeature {
@ObservableState
struct State: Equatable {
var content: String = ""
var isLoading: Bool = false
var errorMessage: String? = nil
var shouldDismiss: Bool = false
var selectedImages: [PhotosPickerItem] = []
var processedImages: [UIImage] = []
var canAddMoreImages: Bool {
processedImages.count < 9
}
var canPublish: Bool {
(!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !processedImages.isEmpty) && !isLoading && !isUploadingImages
}
//
var isUploadingImages: Bool = false
var imageUploadProgress: Double = 0.0 // 0.0~1.0
var uploadedResList: [ResListItem] = []
// EquatableselectedImagesPhotosPickerItemEquatable
static func == (lhs: State, rhs: State) -> Bool {
lhs.content == rhs.content &&
lhs.isLoading == rhs.isLoading &&
lhs.errorMessage == rhs.errorMessage &&
lhs.shouldDismiss == rhs.shouldDismiss &&
lhs.processedImages == rhs.processedImages &&
lhs.selectedImages.count == rhs.selectedImages.count &&
lhs.isUploadingImages == rhs.isUploadingImages &&
lhs.imageUploadProgress == rhs.imageUploadProgress &&
lhs.uploadedResList == rhs.uploadedResList
}
}
enum Action {
case contentChanged(String)
case publishButtonTapped
case publishResponse(Result<PublishFeedResponse, Error>)
case clearError
case dismissView
case clearDismissFlag
// Action
case photosPickerItemsChanged([PhotosPickerItem])
case processPhotosPickerItems([PhotosPickerItem])
case updateProcessedImages([UIImage])
case removeImage(Int)
// Action
case uploadImages
case uploadImagesResponse(Result<[ResListItem], Error>)
//
case updateImageUploadProgress(Double)
}
@Dependency(\.apiService) var apiService
@Dependency(\.dismiss) var dismiss
@Dependency(\.isPresented) var isPresented
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .contentChanged(let newContent):
state.content = newContent
return .none
case .publishButtonTapped:
guard state.canPublish else {
state.errorMessage = "请输入内容"
return .none
}
//
if !state.processedImages.isEmpty {
state.isUploadingImages = true
state.imageUploadProgress = 0.0
state.errorMessage = nil
return .send(.uploadImages)
} else {
state.isLoading = true
state.errorMessage = nil
return .run { [content = state.content] send in
let userId = await UserInfoManager.getCurrentUserId() ?? ""
let type = content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "2" : "0"
let request = await PublishFeedRequest.make(
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
uid: userId,
type: type,
resList: nil
)
do {
let response = try await apiService.request(request)
await send(.publishResponse(.success(response)))
} catch {
await send(.publishResponse(.failure(error)))
}
}
}
case .uploadImages:
let images = state.processedImages
return .run { send in
var resList: [ResListItem] = []
for (idx, image) in images.enumerated() {
guard let data = image.jpegData(compressionQuality: 0.9) else { continue }
if let url = await COSManager.shared.uploadImage(data, apiService: apiService),
let cgImage = image.cgImage {
let width = cgImage.width
let height = cgImage.height
let format = "jpeg"
let item = ResListItem(resUrl: url, width: width, height: height, format: format)
resList.append(item)
}
//
await MainActor.run {
send(.updateImageUploadProgress(Double(idx + 1) / Double(images.count)))
}
}
if resList.count == images.count {
await send(.uploadImagesResponse(.success(resList)))
} else {
await send(.uploadImagesResponse(.failure(NSError(domain: "COSUpload", code: -1, userInfo: [NSLocalizedDescriptionKey: "部分图片上传失败"])) ))
}
}
case .uploadImagesResponse(let result):
state.isUploadingImages = false
state.imageUploadProgress = 1.0
switch result {
case .success(let resList):
state.uploadedResList = resList
state.isLoading = true
return .run { [content = state.content, resList] send in
let userId = await UserInfoManager.getCurrentUserId() ?? ""
// type: 2 /
let type = resList.isEmpty ? "0" : (content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "2" : "2")
let request = await PublishFeedRequest.make(
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
uid: userId,
type: type,
resList: resList
)
do {
let response = try await apiService.request(request)
await send(.publishResponse(.success(response)))
} catch {
await send(.publishResponse(.failure(error)))
}
}
case .failure(let error):
state.errorMessage = error.localizedDescription
return .none
}
case .publishResponse(.success(let response)):
state.isLoading = false
if response.code == 200 {
return .send(.dismissView)
} else {
state.errorMessage = response.message.isEmpty ? "发布失败" : response.message
return .none
}
case .publishResponse(.failure(let error)):
state.isLoading = false
state.errorMessage = error.localizedDescription
return .none
case .clearError:
state.errorMessage = nil
return .none
case .dismissView:
state.shouldDismiss = true
return .none
case .clearDismissFlag:
state.shouldDismiss = false
return .none
case .photosPickerItemsChanged(let items):
state.selectedImages = items
return .run { send in
await send(.processPhotosPickerItems(items))
}
case .processPhotosPickerItems(let items):
let currentImages = state.processedImages
return .run { send in
var newImages = currentImages
for item in items {
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else { continue }
if newImages.count < 9 {
newImages.append(image)
}
}
await MainActor.run {
send(.updateProcessedImages(newImages))
}
}
case .updateProcessedImages(let images):
state.processedImages = images
return .none
case .removeImage(let index):
guard index < state.processedImages.count else { return .none }
state.processedImages.remove(at: index)
if index < state.selectedImages.count {
state.selectedImages.remove(at: index)
}
return .none
//
case .updateImageUploadProgress(let progress):
state.imageUploadProgress = progress
return .none
}
}
}
}

View File

@@ -1,33 +1,123 @@
import Foundation
import ComposableArchitecture
struct FeedListFeature: Reducer {
@Reducer
struct FeedListFeature {
@Dependency(\.apiService) var apiService
struct State: Equatable {
var isFirstLoad: Bool = true
var feeds: [Feed] = [] // feed
var isLoading: Bool = false
var error: String? = nil
var isEditFeedPresented: Bool = false // EditFeedView
//
var moments: [MomentsInfo] = []
//
var isLoaded: Bool = false
//
var currentPage: Int = 1
var hasMore: Bool = true
var isLoadingMore: Bool = false
}
enum Action: Equatable {
case onAppear
case reload
case loadMore
case loadMoreResponse(TaskResult<MomentsLatestResponse>)
case editFeedButtonTapped // add
case editFeedDismissed //
//
case fetchFeeds
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
// Action
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear:
//
return .none
guard state.isFirstLoad else { return .none }
state.isFirstLoad = false
return .send(.fetchFeeds)
case .reload:
//
return .none
//
state.isLoading = true
state.error = nil
state.currentPage = 1
state.hasMore = true
state.isLoaded = true
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 .loadMore:
//
//
guard state.hasMore, !state.isLoadingMore, !state.isLoading else { return .none }
state.isLoadingMore = true
let lastDynamicId: String = {
if let last = state.moments.last {
return String(last.dynamicId)
} else {
return ""
}
}()
return .run { [apiService] send in
await send(.loadMoreResponse(TaskResult {
let request = LatestDynamicsRequest(dynamicId: lastDynamicId, pageSize: 20, types: [.text, .picture])
return try await apiService.request(request)
}))
}
case let .loadMoreResponse(.success(response)):
state.isLoadingMore = false
if let list = response.data?.dynamicList {
if list.isEmpty {
state.hasMore = false
} else {
state.moments.append(contentsOf: list)
state.currentPage += 1
state.hasMore = (list.count >= 20)
}
state.error = nil
} else {
state.hasMore = false
state.error = response.message
}
return .none
case let .loadMoreResponse(.failure(error)):
state.isLoadingMore = false
state.hasMore = false
state.error = error.localizedDescription
return .none
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
state.currentPage = 1
state.hasMore = (list.count >= 20)
} else {
state.moments = []
state.error = response.message
state.hasMore = false
}
return .none
case let .fetchFeedsResponse(.failure(error)):
state.isLoading = false
state.moments = []
state.error = error.localizedDescription
state.hasMore = false
return .none
case .editFeedButtonTapped:
state.isEditFeedPresented = true

View File

@@ -1,30 +1,19 @@
import Foundation
import ComposableArchitecture
@Reducer
struct HomeFeature {
struct HomeFeature: Reducer {
enum Route: Equatable {
case createFeed
}
@ObservableState
struct State: Equatable, Sendable {
struct State: Equatable {
var isInitialized = false
var userInfo: UserInfo?
var accountModel: AccountModel?
var error: String?
//
var isSettingPresented = false
var settingState = SettingFeature.State()
// Feed
var feedState = FeedFeature.State()
//
var meDynamic = MeDynamicFeature.State(uid: 0)
var isLoggedOut = false
//
var route: Route? = nil
}
@@ -37,91 +26,67 @@ struct HomeFeature {
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
// actions
case settingDismissed
case setting(SettingFeature.Action)
// Feed actions
case feed(FeedFeature.Action)
//
case meDynamic(MeDynamicFeature.Action)
case logoutCompleted
// actions
case showCreateFeed
case createFeedDismissed
}
var body: some Reducer<State, Action> {
// Reducer<State, Action>.combine([
// Reducer { state, action in
// switch action {
// case .onAppear:
// guard !state.isInitialized else {
// return Effect.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
// return Effect.none
// case .loadAccountModel:
// return .run { send in
// let accountModel = await UserInfoManager.getAccountModel()
// await send(.accountModelLoaded(accountModel))
// }
// case let .accountModelLoaded(accountModel):
// state.accountModel = accountModel
// return Effect.none
// case .logoutTapped:
// return .send(.logout)
// case .logout:
// return .run { send in
// await UserInfoManager.clearAllAuthenticationData()
// await send(.logoutCompleted)
// }
// case .logoutCompleted:
// state.isLoggedOut = true
// return Effect.none
// case .settingDismissed:
// state.isSettingPresented = false
// return Effect.none
// case .setting:
// return Effect.none
// case .showCreateFeed:
// state.route = .createFeed
// return Effect.none
// case .createFeedDismissed:
// state.route = nil
// return Effect.none
// case .feed:
// return Effect.none
// }
// },
// Scope(
// state: \State.settingState,
// action: /Action.setting,
// child: SettingFeature()
// ),
// Scope(
// state: \State.feedState,
// action: /Action.feed,
// child: FeedFeature()
// )
// ])
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
}
}
}
}
// 使
// extension Notification.Name {
// static let homeLogout = Notification.Name("homeLogout")
// }

View File

@@ -135,15 +135,24 @@ struct IDLoginFeature {
state.loginStep = .completed
debugInfoSync("✅ ID 登录完整流程成功")
debugInfoSync("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
// --- Effect state/accountModel ---
if let ticket = response.ticket, let oldAccountModel = state.accountModel {
// withTicket struct newAccountModel
let newAccountModel = oldAccountModel.withTicket(ticket)
state.accountModel = newAccountModel
// newAccountModel state
return .run { _ in
// state/accountModel Swift
await UserInfoManager.saveAccountModel(newAccountModel)
//
debugInfoSync("🔄 登录成功,开始获取用户信息")
if let _ = await UserInfoManager.fetchUserInfoFromServer(
uid: newAccountModel.uid,
apiService: apiService
) {
debugInfoSync("✅ 用户信息获取成功")
} else {
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
}
}
} else if response.ticket == nil {
state.ticketError = "Ticket 为空"

View File

@@ -10,26 +10,102 @@ struct MainFeature: Reducer {
struct State: Equatable {
var selectedTab: Tab = .feed
var feedList: FeedListFeature.State = .init()
var me: MeFeature.State = .init()
var accountModel: AccountModel? = nil
// State
var navigationPath: [Destination] = []
var appSettingState: AppSettingFeature.State? = nil
//
var isLoggedOut: Bool = false
}
//
enum Destination: Hashable, Equatable {
case test
case appSetting
}
@CasePathable
enum Action: Equatable {
case onAppear
case selectTab(Tab)
case feedList(FeedListFeature.Action)
case me(MeFeature.Action)
case accountModelLoaded(AccountModel?)
//
case navigationPathChanged([Destination])
case testButtonTapped
case appSettingButtonTapped
case appSettingAction(AppSettingFeature.Action)
//
case logout
}
var body: some ReducerOf<Self> {
Scope(state: \.feedList, action: \.feedList) {
FeedListFeature()
}
Scope(state: \.me, action: \.me) {
MeFeature()
}
Reduce { state, action in
switch action {
case .onAppear:
return .run { send in
let accountModel = await UserInfoManager.getAccountModel()
await send(.accountModelLoaded(accountModel))
}
case .selectTab(let tab):
state.selectedTab = tab
state.navigationPath = []
if tab == .other, let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.uid != uid {
state.me.uid = uid
state.me.isFirstLoad = true //
}
return .send(.me(.onAppear))
}
return .none
case .feedList:
return .none
}
case let .accountModelLoaded(accountModel):
state.accountModel = accountModel
return .none
case .me(.settingButtonTapped):
// push
state.appSettingState = AppSettingFeature.State()
state.navigationPath.append(.appSetting)
return .none
case .me:
return .none
case .navigationPathChanged(let newPath):
// pop settingState
if !newPath.contains(.appSetting) {
state.appSettingState = nil
}
state.navigationPath = newPath
return .none
case .testButtonTapped:
state.navigationPath.append(.test)
return .none
case .appSettingButtonTapped:
state.appSettingState = AppSettingFeature.State()
state.navigationPath.append(.appSetting)
return .none
case .appSettingAction(.logoutTapped):
//
state.isLoggedOut = true
return .none
case .appSettingAction:
return .none
case .logout:
// SplashView/SplashFeature
return .none
}
}
//
.ifLet(\ .appSettingState, action: \.appSettingAction) {
AppSettingFeature()
}
}
}

View File

@@ -0,0 +1,91 @@
import Foundation
import ComposableArchitecture
@Reducer
struct MeDynamicFeature: Reducer {
struct State: Equatable {
var uid: Int
var dynamics: [MomentsInfo] = []
var page: Int = 1
var pageSize: Int = 20
var isLoading: Bool = false
var isRefreshing: Bool = false
var isLoadingMore: Bool = false
var hasMore: Bool = true
var error: String?
var isInitialized: Bool = false //
}
enum Action: Equatable {
case onAppear
case refresh
case loadMore
case fetchResponse(Result<MyMomentsResponse, APIError>)
}
@Dependency(\.apiService) var apiService
func reduce(into state: inout State, action: Action) async -> Effect<Action> {
switch action {
case .onAppear:
guard !state.isInitialized else { return .none }
state.isInitialized = true
state.page = 1
state.dynamics = []
state.hasMore = true
state.isLoading = true
state.error = nil
return fetchDynamics(uid: state.uid, page: 1, pageSize: state.pageSize)
case .refresh:
state.page = 1
state.hasMore = true
state.isRefreshing = true
state.error = nil
state.isInitialized = false //
return fetchDynamics(
uid: state.uid,
page: 1,
pageSize: state.pageSize
)
case .loadMore:
guard !state.isLoadingMore, state.hasMore else { return .none }
state.isLoadingMore = true
return fetchDynamics(uid: state.uid, page: state.page + 1, pageSize: state.pageSize)
case let .fetchResponse(result):
state.isLoading = false
state.isRefreshing = false
state.isLoadingMore = false
switch result {
case let .success(resp):
let newDynamics = resp.data ?? []
if state.page == 1 {
state.dynamics = newDynamics
} else {
state.dynamics += newDynamics
}
state.hasMore = newDynamics.count == state.pageSize
if state.hasMore { state.page += 1 }
state.error = nil
case let .failure(error):
state.error = error.localizedDescription
}
return .none
}
}
private func fetchDynamics(uid: Int, page: Int, pageSize: Int) -> Effect<Action> {
let apiService = self.apiService
return .run { send in
do {
let req = GetMyDynamicRequest(fromUid: uid, uid: uid, page: page, pageSize: pageSize)
let resp = try await apiService.request(req)
await send(.fetchResponse(.success(resp)))
} catch {
await send(.fetchResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
}
}
}
}

View File

@@ -0,0 +1,113 @@
import Foundation
import ComposableArchitecture
@Reducer
struct MeFeature {
@Dependency(\.apiService) var apiService
struct State: Equatable {
var isFirstLoad: Bool = true
var userInfo: UserInfo?
var isLoadingUserInfo: Bool = false
var userInfoError: String?
var moments: [MomentsInfo] = []
var isLoadingMoments: Bool = false
var momentsError: String?
var hasMore: Bool = true
var isLoadingMore: Bool = false
var isRefreshing: Bool = false
var page: Int = 1
var pageSize: Int = 20
var uid: Int = 0
}
enum Action: Equatable {
case onAppear
case refresh
case loadMore
case userInfoResponse(Result<UserInfo, APIError>)
case momentsResponse(Result<MyMomentsResponse, APIError>)
//
case settingButtonTapped
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear:
guard state.isFirstLoad else { return .none }
state.isFirstLoad = false
return .send(.refresh)
case .refresh:
guard state.uid > 0 else { return .none }
state.isRefreshing = true
state.page = 1
state.hasMore = true
return .merge(
fetchUserInfo(uid: state.uid),
fetchMoments(uid: state.uid, page: 1, pageSize: state.pageSize)
)
case .loadMore:
guard state.uid > 0, state.hasMore, !state.isLoadingMore else { return .none }
state.isLoadingMore = true
return fetchMoments(uid: state.uid, page: state.page + 1, pageSize: state.pageSize)
case let .userInfoResponse(result):
state.isLoadingUserInfo = false
state.isRefreshing = false
switch result {
case let .success(userInfo):
state.userInfo = userInfo
state.userInfoError = nil
case let .failure(error):
state.userInfoError = error.localizedDescription
}
return .none
case let .momentsResponse(result):
state.isLoadingMoments = false
state.isLoadingMore = false
state.isRefreshing = false
switch result {
case let .success(resp):
let newMoments = resp.data ?? []
if state.page == 1 {
state.moments = newMoments
} else {
state.moments += newMoments
}
state.hasMore = newMoments.count == state.pageSize
if state.hasMore { state.page += 1 }
state.momentsError = nil
case let .failure(error):
state.momentsError = error.localizedDescription
}
return .none
case .settingButtonTapped:
// MainFeature
return .none
}
}
private func fetchUserInfo(uid: Int) -> Effect<Action> {
.run { send in
do {
if let userInfo = try await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
await send(.userInfoResponse(.success(userInfo)))
} else {
await send(.userInfoResponse(.failure(.noData)))
}
} catch {
await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
}
}
}
private func fetchMoments(uid: Int, page: Int, pageSize: Int) -> Effect<Action> {
.run { send in
do {
let req = GetMyDynamicRequest(fromUid: uid, uid: uid, page: page, pageSize: pageSize)
let resp = try await apiService.request(req)
await send(.momentsResponse(.success(resp)))
} catch {
await send(.momentsResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
}
}
}
}

View File

@@ -1,75 +0,0 @@
import Foundation
import ComposableArchitecture
@Reducer
struct SettingFeature {
@ObservableState
struct State: Equatable {
var userInfo: UserInfo?
var accountModel: AccountModel?
var isLoading = false
var error: String?
}
enum Action: Equatable {
case onAppear
case loadUserInfo
case userInfoLoaded(UserInfo?)
case loadAccountModel
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
case dismissTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
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
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:
state.isLoading = true
return .run { _ in
await UserInfoManager.clearAllAuthenticationData()
}
case .dismissTapped:
// NotificationCenter.default.post(name: .settingsDismiss, object: nil)
// action
return .none
}
}
}
}
// 使
// extension Notification.Name {
// static let settingsDismiss = Notification.Name("settingsDismiss")
// }

View File

@@ -26,11 +26,17 @@ struct SplashFeature {
case checkAuthentication
case authenticationChecked(UserInfoManager.AuthenticationStatus)
// actions
case fetchUserInfo
case userInfoFetched(Bool)
// actions
case navigateToLogin
case navigateToMain
}
@Dependency(\.apiService) var apiService // API
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
@@ -65,15 +71,31 @@ struct SplashFeature {
state.isCheckingAuthentication = false
state.authenticationStatus = status
//
//
if status.canAutoLogin {
debugInfoSync("🎉 自动登录成功,进入主页")
return .send(.navigateToMain)
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
//
return .send(.fetchUserInfo)
} else {
debugInfoSync("🔑 需要手动登录")
return .send(.navigateToLogin)
}
case .fetchUserInfo:
//
return .run { send in
let success = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: apiService)
await send(.userInfoFetched(success))
}
case let .userInfoFetched(success):
if success {
debugInfoSync("✅ 用户信息获取成功,进入主页")
} else {
debugInfoSync("⚠️ 用户信息获取失败,但仍进入主页")
}
return .send(.navigateToMain)
case .navigateToLogin:
state.navigationDestination = .login
return .none

View File

@@ -2,11 +2,10 @@
Localizable.strings
yana
Created on 2024.
英文本地化文件
English localization file (auto-aligned)
*/
// MARK: - 登录界面
// MARK: - Login Screen
"login.id_login" = "ID Login";
"login.email_login" = "Email Login";
"login.app_title" = "E-PARTI";
@@ -14,32 +13,32 @@
"login.agreement" = "User Service Agreement";
"login.policy" = "Privacy Policy";
// MARK: - 通用按钮
// MARK: - Common Buttons
"common.login" = "Login";
"common.register" = "Register";
"common.cancel" = "Cancel";
"common.confirm" = "Confirm";
"common.ok" = "OK";
// MARK: - 错误信息
// MARK: - Error Messages
"error.network" = "Network Error";
"error.invalid_input" = "Invalid Input";
"error.login_failed" = "Login Failed";
// MARK: - 占位符文本
// MARK: - Placeholders
"placeholder.email" = "Enter your email";
"placeholder.password" = "Enter your password";
"placeholder.username" = "Enter your username";
"placeholder.enter_id" = "Please enter ID";
"placeholder.enter_password" = "Please enter password";
// MARK: - ID登录页面
// MARK: - ID Login Page
"id_login.title" = "ID Login";
"id_login.forgot_password" = "Forgot Password?";
"id_login.login_button" = "Login";
"id_login.logging_in" = "Logging in...";
// MARK: - 邮箱登录页面
// MARK: - Email Login Page
"email_login.title" = "Email Login";
"email_login.email_required" = "Please enter email";
"email_login.invalid_email" = "Please enter a valid email address";
@@ -52,13 +51,13 @@
"placeholder.enter_email" = "Please enter email";
"placeholder.enter_verification_code" = "Please enter verification code";
// MARK: - 验证和错误信息
// MARK: - Validation and Error Messages
"validation.id_required" = "Please enter your ID";
"validation.password_required" = "Please enter your password";
"error.encryption_failed" = "Encryption failed, please try again";
"error.login_failed" = "Login failed, please check your credentials";
// MARK: - 密码恢复页面
// MARK: - Password Recovery Page
"recover_password.title" = "Recover Password";
"recover_password.placeholder_email" = "Please enter email";
"recover_password.placeholder_verification_code" = "Please enter verification code";
@@ -74,5 +73,60 @@
"recover_password.reset_success" = "Password reset successfully";
"recover_password.resetting" = "Resetting...";
// MARK: - 主页
// MARK: - Home
"home.title" = "Enjoy your Life Time";
// MARK: - Create Feed
"createFeed.enterContent" = "Enter Content";
"createFeed.processingImages" = "Processing images...";
"createFeed.publishing" = "Publishing...";
"createFeed.publish" = "Publish";
"createFeed.title" = "Image & Text Publish";
// MARK: - Edit Feed
"editFeed.title" = "Image & Text Edit";
"editFeed.publish" = "Publish";
"editFeed.enterContent" = "Enter Content";
// MARK: - Feed List
"feedList.title" = "Enjoy your Life Time";
"feedList.slogan" = "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.";
// MARK: - Feed
"feed.title" = "Enjoy your Life Time";
"feed.empty" = "No moments yet";
"feed.error" = "Error: %@";
"feed.retry" = "Retry";
"feed.loadingMore" = "Loading more...";
"me.title" = "Me";
"me.nickname" = "Nickname";
"me.id" = "ID: %@";
"language.select" = "Select Language";
"language.current" = "Current Language";
"language.info" = "Language Info";
"feed.user" = "User %d";
"feed.2hoursago" = "2 hours ago";
"feed.demoContent" = "Today is a beautiful day, sharing some little happiness in life. Hope everyone cherishes every moment.";
"feed.vip" = "VIP%d";
// MARK: - Splash
"splash.title" = "E-Parti";
// MARK: - Setting
"setting.title" = "Settings";
"setting.user" = "User";
"setting.language" = "Language Settings";
"setting.about" = "About Us";
"setting.version" = "Version Info";
"setting.logout" = "Logout";
// MARK: - App Setting
"appSetting.title" = "Edit";
"appSetting.nickname" = "Nickname";
"appSetting.personalInfoPermissions" = "Personal Information and Permissions";
"appSetting.help" = "Help";
"appSetting.clearCache" = "Clear Cache";
"appSetting.checkUpdates" = "Check for Updates";
"appSetting.logout" = "Log Out";
"appSetting.aboutUs" = "About Us";
"appSetting.logoutAccount" = "Log out of account";

View File

@@ -76,3 +76,53 @@
// MARK: - 主页
"home.title" = "享受您的生活时光";
"createFeed.enterContent" = "输入内容";
"createFeed.processingImages" = "处理图片中...";
"createFeed.publishing" = "发布中...";
"createFeed.publish" = "发布";
"createFeed.title" = "图文发布";
"editFeed.title" = "图文发布";
"editFeed.publish" = "发布";
"editFeed.enterContent" = "输入内容";
"feedList.title" = "享受您的生活时光";
"feedList.slogan" = "疾病如同残酷的统治者,\n而时间是我们最宝贵的财富。\n我们活着的每一刻都是对不可避免命运的胜利。";
"feed.title" = "享受您的生活时光";
"feed.empty" = "暂无动态内容";
"feed.error" = "错误: %@";
"feed.retry" = "重试";
"feed.loadingMore" = "加载更多...";
"splash.title" = "E-Parti";
"setting.title" = "设置";
"setting.user" = "用户";
"setting.language" = "语言设置";
"setting.about" = "关于我们";
"setting.version" = "版本信息";
"setting.logout" = "退出登录";
"me.title" = "我的";
"me.nickname" = "用户昵称";
"me.id" = "ID: %@";
"language.select" = "选择语言";
"language.current" = "当前语言";
"language.info" = "语言信息";
"feed.user" = "用户%d";
"feed.2hoursago" = "2小时前";
"feed.demoContent" = "今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。";
"feed.vip" = "VIP%d";
// MARK: - App Setting
"appSetting.title" = "编辑";
"appSetting.nickname" = "昵称";
"appSetting.personalInfoPermissions" = "个人信息与权限";
"appSetting.help" = "帮助";
"appSetting.clearCache" = "清除缓存";
"appSetting.checkUpdates" = "检查更新";
"appSetting.logout" = "退出登录";
"appSetting.aboutUs" = "关于我们";
"appSetting.logoutAccount" = "退出账户";

View File

@@ -1,5 +1,5 @@
import Foundation
import ComposableArchitecture
import QCloudCOSXML
// MARK: - COS
@@ -15,6 +15,26 @@ class COSManager: ObservableObject {
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
/// Token
@@ -102,13 +122,97 @@ class COSManager: ObservableObject {
/// Token
func getTokenStatus() -> String {
if let cached = cachedToken, let expiration = tokenExpirationDate {
if let _ = cachedToken, let expiration = tokenExpirationDate {
let isExpired = Date() >= expiration
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
} else {
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 prefix = domain.hasPrefix("http") ? "" : "https://"
let cloudURL = "\(prefix)\(domain)/\(key)"
debugInfoSync("✅ 图片上传成功: \(cloudURL)")
continuation.resume(returning: cloudURL)
}
}
QCloudCOSTransferMangerService.defaultCOSTransferManager().uploadObject(request)
}
}
/// UIImage COS JPEG(0.8)
/// - Parameters:
/// - image: UIImage
/// - apiService: API
/// - Returns: nil
func uploadUIImage(_ image: UIImage, apiService: any APIServiceProtocol & Sendable) async -> String? {
guard let data = image.jpegData(compressionQuality: 0.8) else {
debugInfoSync("❌ 图片压缩失败,无法生成 JPEG 数据")
return nil
}
return await uploadImage(data, apiService: apiService)
}
}
// MARK: -

View File

@@ -23,4 +23,28 @@ extension Color {
let blue = Double(hex & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue, opacity: alpha)
}
init(hexString: String) {
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View File

@@ -0,0 +1,258 @@
import SwiftUI
import ComposableArchitecture
struct AppSettingView: View {
let store: StoreOf<AppSettingFeature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
ZStack {
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
// VStack(spacing: 0) {
//
VStack(spacing: 0) {
//
avatarSection(viewStore: viewStore)
//
nicknameSection(viewStore: viewStore)
//
settingsSection(viewStore: viewStore)
Spacer()
//
logoutButton(viewStore: viewStore)
}
// }
}
.navigationTitle(NSLocalizedString("appSetting.title", comment: "Edit"))
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
}
}
}
.onAppear {
viewStore.send(.onAppear)
}
.webView(
isPresented: userAgreementBinding(viewStore: viewStore),
url: APIConfiguration.webURL(for: .userAgreement)
)
.webView(
isPresented: privacyPolicyBinding(viewStore: viewStore),
url: APIConfiguration.webURL(for: .privacyPolicy)
)
}
}
// MARK: -
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
ZStack(alignment: .bottomTrailing) {
avatarImageView(viewStore: viewStore)
cameraButton
}
.padding(.top, 24)
}
// MARK: -
@ViewBuilder
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
if viewStore.isLoadingUserInfo {
loadingAvatarView
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
networkAvatarView(url: avatarURL)
} else {
defaultAvatarView
}
}
// MARK: -
private var loadingAvatarView: some View {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 120, height: 120)
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
)
}
// MARK: -
private func networkAvatarView(url: URL) -> some View {
CachedAsyncImage(url: url.absoluteString) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
defaultAvatarView
}
.frame(width: 120, height: 120)
.clipShape(Circle())
}
// MARK: -
private var defaultAvatarView: some View {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 120, height: 120)
.overlay(
Image(systemName: "person.fill")
.font(.system(size: 40))
.foregroundColor(.white)
)
}
// MARK: -
private var cameraButton: some View {
Button(action: {}) {
ZStack {
Circle().fill(Color.purple).frame(width: 36, height: 36)
Image(systemName: "camera.fill")
.foregroundColor(.white)
}
}
.offset(x: 8, y: 8)
}
// MARK: -
private func nicknameSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
VStack(spacing: 0) {
HStack {
Text(NSLocalizedString("appSetting.nickname", comment: "Nickname"))
.foregroundColor(.white)
Spacer()
Text(viewStore.nickname)
.foregroundColor(.gray)
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding(.horizontal, 32)
.padding(.vertical, 18)
.onTapGesture {
viewStore.send(.editNicknameTapped)
}
Divider().background(Color.gray.opacity(0.3))
.padding(.horizontal, 32)
}
}
// MARK: -
private func settingsSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
VStack(spacing: 0) {
personalInfoPermissionsRow(viewStore: viewStore)
helpRow(viewStore: viewStore)
clearCacheRow(viewStore: viewStore)
checkUpdatesRow(viewStore: viewStore)
aboutUsRow(viewStore: viewStore)
}
.background(Color.clear)
.padding(.horizontal, 0)
}
// MARK: -
private func personalInfoPermissionsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.personalInfoPermissions", comment: "Personal Information and Permissions"),
action: { viewStore.send(.personalInfoPermissionsTapped) }
)
}
// MARK: -
private func helpRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.help", comment: "Help"),
action: { viewStore.send(.helpTapped) }
)
}
// MARK: -
private func clearCacheRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.clearCache", comment: "Clear Cache"),
action: { viewStore.send(.clearCacheTapped) }
)
}
// MARK: -
private func checkUpdatesRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.checkUpdates", comment: "Check for Updates"),
action: { viewStore.send(.checkUpdatesTapped) }
)
}
// MARK: -
private func aboutUsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.aboutUs", comment: "About Us"),
action: { viewStore.send(.aboutUsTapped) }
)
}
// MARK: -
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
VStack(spacing: 0) {
HStack {
Text(title)
.foregroundColor(.white)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding(.horizontal, 32)
.padding(.vertical, 18)
.onTapGesture {
action()
}
Divider().background(Color.gray.opacity(0.3))
.padding(.horizontal, 32)
}
}
// MARK: - 退
private func logoutButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
Button(action: {
viewStore.send(.logoutTapped)
}) {
Text(NSLocalizedString("appSetting.logoutAccount", comment: "Log out of account"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Color.white.opacity(0.08))
.cornerRadius(28)
.padding(.horizontal, 32)
}
.padding(.bottom, 32)
}
// MARK: -
private func userAgreementBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
viewStore.binding(
get: \.showUserAgreement,
send: AppSettingFeature.Action.userAgreementDismissed
)
}
// MARK: -
private func privacyPolicyBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
viewStore.binding(
get: \.showPrivacyPolicy,
send: AppSettingFeature.Action.privacyPolicyDismissed
)
}
}

View File

@@ -0,0 +1,294 @@
import SwiftUI
import ComposableArchitecture
import Foundation
// MARK: -
struct OptimizedDynamicCardView: View {
let moment: MomentsInfo
let allMoments: [MomentsInfo]
let currentIndex: Int
//
@State private var showPreview = false
@State private var previewImageUrls: [String] = []
@State private var previewIndex: Int = 0
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(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)
Text("ID: \(moment.uid)")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
Spacer()
// VIP
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 {
OptimizedImageGrid(images: images) { tappedIndex in
previewImageUrls = images.map { $0.resUrl ?? "" }
previewIndex = tappedIndex
showPreview = true
}
.padding(.bottom, images.count == 2 ? 16 : 0) //
}
//
HStack(spacing: 20) {
// Like
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()
}
//
.fullScreenCover(isPresented: $showPreview) {
ImagePreviewPager(images: previewImageUrls, currentIndex: $previewIndex) {
showPreview = false
previewImageUrls = []
}
}
}
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 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)
}
}
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.compactMap { $0.resUrl })
}
}
ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
}
// UIImageURL
}
// MARK: -
struct OptimizedImageGrid: View {
let images: [MomentsPicture]
let onImageTap: (Int) -> Void
init(images: [MomentsPicture], onImageTap: @escaping (Int) -> Void) {
self.images = images
self.onImageTap = onImageTap
}
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) {
onImageTap(0)
}
Spacer()
}
case 2:
let imageSize: CGFloat = (availableWidth - spacing) / 2
HStack(spacing: spacing) {
SquareImageView(image: images[0], size: imageSize) {
onImageTap(0)
}
SquareImageView(image: images[1], size: imageSize) {
onImageTap(1)
}
}
case 3:
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
HStack(spacing: spacing) {
ForEach(Array(images.prefix(3).enumerated()), id: \ .element.id) { idx, image in
SquareImageView(image: image, size: imageSize) {
onImageTap(idx)
}
}
}
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) { idx, image in
SquareImageView(image: image, size: imageSize) {
onImageTap(idx)
}
}
}
}
}
}
.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
let onTap: (() -> Void)?
init(image: MomentsPicture, size: CGFloat, onTap: (() -> Void)? = nil) {
self.image = image
self.size = size
self.onTap = onTap
}
public var body: some View {
let safeSize = size.isFinite && size > 0 ? size : 100
Group {
if let onTap = onTap {
Button(action: onTap) {
imageContent
}
.buttonStyle(PlainButtonStyle())
} else {
imageContent
}
}
.frame(width: safeSize, height: safeSize)
.clipped()
.cornerRadius(8)
}
private var imageContent: some View {
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)
)
}
}
}

View File

@@ -0,0 +1,12 @@
import SwiftUI
extension View {
@ViewBuilder
func isHidden(_ hidden: Bool) -> some View {
if hidden {
self.opacity(0).allowsHitTesting(false)
} else {
self
}
}
}

View File

@@ -25,7 +25,7 @@ struct CreateFeedView: View {
.frame(height: 200) // 200
if store.content.isEmpty {
Text("Enter Content")
Text(NSLocalizedString("createFeed.enterContent", comment: "Enter Content"))
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 16)
.padding(.vertical, 12)
@@ -79,7 +79,7 @@ struct CreateFeedView: View {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("处理图片中...")
Text(NSLocalizedString("createFeed.processingImages", comment: "Processing images..."))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
@@ -111,11 +111,11 @@ struct CreateFeedView: View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
Text("发布中...")
Text(NSLocalizedString("createFeed.publishing", comment: "Publishing..."))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
} else {
Text("发布")
Text(NSLocalizedString("createFeed.publish", comment: "Publish"))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
@@ -137,7 +137,7 @@ struct CreateFeedView: View {
)
}
}
.navigationTitle("图文发布")
.navigationTitle(NSLocalizedString("createFeed.title", comment: "Image & Text Publish"))
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.navigationBarBackButtonHidden(true)
@@ -168,66 +168,66 @@ struct CreateFeedView: View {
}
// MARK: - iOS 16+
struct ModernImageSelectionGrid: View {
let images: [UIImage]
let selectedItems: [PhotosPickerItem]
let canAddMore: Bool
let onItemsChanged: ([PhotosPickerItem]) -> Void
let onRemoveImage: (Int) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
var body: some View {
WithPerceptionTracking {
LazyVGrid(columns: columns, spacing: 8) {
//
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 100)
.clipped()
.cornerRadius(8)
//
Button(action: {
onRemoveImage(index)
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
}
.padding(4)
}
}
//
if canAddMore {
PhotosPicker(
selection: .init(
get: { selectedItems },
set: onItemsChanged
),
maxSelectionCount: 9,
matching: .images
) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.1))
.frame(height: 100)
.overlay(
Image(systemName: "plus")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
)
}
}
}
}
}
}
//struct ModernImageSelectionGrid: View {
// let images: [UIImage]
// let selectedItems: [PhotosPickerItem]
// let canAddMore: Bool
// let onItemsChanged: ([PhotosPickerItem]) -> Void
// let onRemoveImage: (Int) -> Void
//
// private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
//
// var body: some View {
// WithPerceptionTracking {
// LazyVGrid(columns: columns, spacing: 8) {
// //
// ForEach(Array(images.enumerated()), id: \.offset) { index, image in
// ZStack(alignment: .topTrailing) {
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// .frame(height: 100)
// .clipped()
// .cornerRadius(8)
//
// //
// Button(action: {
// onRemoveImage(index)
// }) {
// Image(systemName: "xmark.circle.fill")
// .font(.system(size: 20))
// .foregroundColor(.white)
// .background(Color.black.opacity(0.6))
// .clipShape(Circle())
// }
// .padding(4)
// }
// }
//
// //
// if canAddMore {
// PhotosPicker(
// selection: .init(
// get: { selectedItems },
// set: onItemsChanged
// ),
// maxSelectionCount: 9,
// matching: .images
// ) {
// RoundedRectangle(cornerRadius: 8)
// .fill(Color.white.opacity(0.1))
// .frame(height: 100)
// .overlay(
// Image(systemName: "plus")
// .font(.system(size: 40))
// .foregroundColor(.white.opacity(0.6))
// )
// }
// }
// }
// }
// }
//}
// MARK: -
//#Preview {

View File

@@ -1,19 +1,299 @@
import SwiftUI
import ComposableArchitecture
import PhotosUI
//import ImagePreviewPager
struct EditFeedView: View {
var body: some View {
VStack(spacing: 20) {
Text("编辑动态")
.font(.title)
.bold()
Text("这里是 EditFeedView 占位内容")
.foregroundColor(.gray)
Spacer()
let onDismiss: () -> Void
let store: StoreOf<EditFeedFeature>
@State private var isKeyboardVisible = false
private let maxCount = 500
private func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
WithViewStore(store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
ZStack {
backgroundView
mainContent(geometry: geometry, viewStore: viewStore)
if viewStore.isUploadingImages {
uploadingImagesOverlay(progress: viewStore.imageUploadProgress)
} else if viewStore.isLoading {
loadingOverlay
}
}
.contentShape(Rectangle())
.onTapGesture {
if isKeyboardVisible {
hideKeyboard()
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = true
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = false
}
}
.onChange(of: viewStore.errorMessage) { error in
if error != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
viewStore.send(.clearError)
}
}
}
.onChange(of: viewStore.shouldDismiss) { shouldDismiss in
if shouldDismiss {
onDismiss()
NotificationCenter.default.post(name: Notification.Name("reloadFeedList"), object: nil)
viewStore.send(.clearDismissFlag)
}
}
}
}
}
.padding()
}
}
#Preview {
EditFeedView()
private var backgroundView: some View {
Color(hexString: "0C0527")
.ignoresSafeArea()
}
private func mainContent(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
VStack(spacing: 0) {
headerView(geometry: geometry, viewStore: viewStore)
textInputArea(viewStore: viewStore)
//
ModernImageSelectionGrid(
images: viewStore.processedImages,
selectedItems: viewStore.selectedImages,
canAddMore: viewStore.canAddMoreImages,
onItemsChanged: { items in
viewStore.send(.photosPickerItemsChanged(items))
},
onRemoveImage: { index in
viewStore.send(.removeImage(index))
}
)
.padding(.horizontal, 24)
.padding(.bottom, 32)
Spacer()
if !isKeyboardVisible {
publishButtonBottom(viewStore: viewStore, geometry: geometry)
}
}
}
private func headerView(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
HStack {
Text(NSLocalizedString("editFeed.title", comment: "Image & Text Edit"))
.font(.system(size: 20, weight: .semibold))
.foregroundColor(.white)
Spacer()
if isKeyboardVisible {
WithPerceptionTracking {
Button(action: {
hideKeyboard()
viewStore.send(.publishButtonTapped)
}) {
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
LinearGradient(
colors: [
Color(hexString: "A14AC6"),
Color(hexString: "3B1EEB")
],
startPoint: .leading,
endPoint: .trailing
)
.cornerRadius(16)
)
}
.disabled(!viewStore.canPublish)
}
}
}
.padding(.horizontal, 24)
.padding(.top, geometry.safeAreaInsets.top + 16)
.padding(.bottom, 24)
}
private func textInputArea(viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 20)
.fill(Color(hexString: "1C143A"))
TextEditor(text: Binding(
get: { viewStore.content },
set: { viewStore.send(.contentChanged($0)) }
))
.scrollContentBackground(.hidden)
.padding(16)
.frame(height: 160)
.foregroundColor(.white)
.background(.clear)
.cornerRadius(20)
.font(.system(size: 16))
if viewStore.content.isEmpty {
Text(NSLocalizedString("editFeed.enterContent", comment: "Enter Content"))
.foregroundColor(Color.white.opacity(0.4))
.padding(20)
.font(.system(size: 16))
}
WithPerceptionTracking {
VStack {
Spacer()
HStack {
Spacer()
Text("\(viewStore.content.count)/\(maxCount)")
.foregroundColor(Color.white.opacity(0.4))
.font(.system(size: 14))
.padding(.trailing, 16)
.padding(.bottom, 10)
}
}
}
}
.frame(height: 160)
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
private func publishButtonBottom(viewStore: ViewStoreOf<EditFeedFeature>, geometry: GeometryProxy) -> some View {
VStack {
Spacer()
Button(action: {
hideKeyboard()
viewStore.send(.publishButtonTapped)
}) {
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: [Color(hexString: "A14AC6"), Color(hexString: "3B1EEB")],
startPoint: .leading,
endPoint: .trailing
)
.cornerRadius(28)
)
}
.padding(.horizontal, 24)
.padding(.bottom, geometry.safeAreaInsets.bottom + 24)
.disabled(!viewStore.canPublish || viewStore.isUploadingImages || viewStore.isLoading)
.opacity(viewStore.canPublish ? 1.0 : 0.5)
}
}
private var loadingOverlay: some View {
Group {
Color.black.opacity(0.3)
.ignoresSafeArea()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.5)
}
}
//
private func uploadingImagesOverlay(progress: Double) -> some View {
Group {
Color.black.opacity(0.3)
.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.frame(width: 180)
Text("正在上传图片...\(Int(progress * 100))%")
.foregroundColor(.white)
.font(.system(size: 16, weight: .medium))
}
}
}
}
//#Preview {
// EditFeedView()
//}
// MARK: -
struct ModernImageSelectionGrid: View {
let images: [UIImage]
let selectedItems: [PhotosPickerItem]
let canAddMore: Bool
let onItemsChanged: ([PhotosPickerItem]) -> Void
let onRemoveImage: (Int) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
@State private var showPreview = false
@State private var previewIndex = 0
var body: some View {
let totalSpacing: CGFloat = 8 * 2
let totalWidth = UIScreen.main.bounds.width - 24 * 2 - totalSpacing
let gridItemSize: CGFloat = totalWidth / 3
WithPerceptionTracking {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill) // aspectFill
.frame(width: gridItemSize, height: gridItemSize)
.clipped()
.cornerRadius(12)
.onTapGesture {
previewIndex = index
showPreview = true
}
Button(action: {
onRemoveImage(index)
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
}
.padding(4)
}
}
if canAddMore {
PhotosPicker(
selection: .init(
get: { selectedItems },
set: { items in DispatchQueue.main.async { onItemsChanged(items) } }
),
maxSelectionCount: 9 - images.count,
matching: .images
) {
RoundedRectangle(cornerRadius: 12)
.fill(Color(hexString: "1C143A"))
.frame(width: gridItemSize, height: gridItemSize)
.overlay(
Image("add photo")
.resizable()
.frame(width: 40, height: 40)
.opacity(0.6)
)
}
}
}
.fullScreenCover(isPresented: $showPreview) {
ImagePreviewPager(images: images, currentIndex: $previewIndex, onClose: { showPreview = false })
}
}
}
}

View File

@@ -1,5 +1,6 @@
import SwiftUI
import ComposableArchitecture
//import OptimizedDynamicCardView //
struct FeedListView: View {
let store: StoreOf<FeedListFeature>
@@ -20,7 +21,7 @@ struct FeedListView: View {
HStack {
Spacer(minLength: 0)
Spacer(minLength: 0)
Text("Enjoy your Life Time")
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .center)
@@ -40,12 +41,58 @@ struct FeedListView: View {
.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.")
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(.center)
.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: "暂无动态"))
.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)
//
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)
}
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 20)
}
.refreshable {
viewStore.send(.reload)
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
@@ -54,11 +101,23 @@ struct FeedListView: View {
.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()
EditFeedView(
onDismiss: {
viewStore.send(.editFeedDismissed)
},
store: Store(
initialState: EditFeedFeature.State()
) {
EditFeedFeature()
}
)
}
}
}

View File

@@ -8,12 +8,12 @@ struct FeedTopBarView: View {
WithPerceptionTracking {
HStack {
Spacer()
Text("Enjoy your Life Time")
Text(NSLocalizedString("feed.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
Spacer()
Button(action: {
onShowCreateFeed() //
// showEditFeed = true //
}) {
Image("add icon")
.frame(width: 36, height: 36)
@@ -34,11 +34,11 @@ struct FeedMomentsListView: View {
Image(systemName: "heart.text.square")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
Text("暂无动态内容")
Text(NSLocalizedString("feed.empty", comment: "No moments yet"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
if let error = store.error {
Text("错误: \(error)")
Text(String(format: NSLocalizedString("feed.error", comment: "Error: %@"), error))
.font(.system(size: 12))
.foregroundColor(.red.opacity(0.8))
.multilineTextAlignment(.center)
@@ -50,7 +50,7 @@ struct FeedMomentsListView: View {
Button(action: {
store.send(.retryLoad)
}) {
Text("重试")
Text(NSLocalizedString("feed.retry", comment: "Retry"))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
@@ -85,7 +85,7 @@ struct FeedMomentsListView: View {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text("加载更多...")
Text(NSLocalizedString("feed.loadingMore", comment: "Loading more..."))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
@@ -102,6 +102,7 @@ struct FeedMomentsListView: View {
struct FeedView: View {
let store: StoreOf<FeedFeature>
let onShowCreateFeed: () -> Void
@State private var showEditFeed = false
var body: some View {
WithPerceptionTracking {
@@ -154,465 +155,477 @@ struct FeedView: View {
.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)
}
}
//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 + + )
}
}
}
//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)
}
}
//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)
}
}
}
//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)
)
}
}
//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(

View File

@@ -34,8 +34,15 @@ struct HomeView: View {
)
.transition(.opacity)
case .me:
MeView(onLogout: onLogout)
.transition(.opacity)
Spacer()
// MeView(
// meDynamicStore: store.scope(
// state: \.meDynamic,
// action: \.meDynamic
// ),
// onLogout: onLogout
// )
// .transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -51,12 +58,6 @@ struct HomeView: View {
.onAppear {
store.send(.onAppear)
}
.sheet(isPresented: Binding(
get: { store.withState(\.isSettingPresented) },
set: { _ in store.send(.settingDismissed) }
)) {
SettingView(store: store.scope(state: \.settingState, action: \.setting))
}
.navigationDestination(isPresented: Binding(
get: { store.withState(\.route) == .createFeed },
set: { isPresented in

View File

@@ -0,0 +1,87 @@
import SwiftUI
enum ImagePreviewSource: Identifiable, Equatable {
case local(UIImage)
case remote(String)
var id: String {
switch self {
case .local(let img):
return String(describing: img.hashValue)
case .remote(let url):
return url
}
}
static func == (lhs: ImagePreviewSource, rhs: ImagePreviewSource) -> Bool {
switch (lhs, rhs) {
case let (.local(l), .local(r)):
return l.pngData() == r.pngData()
case let (.remote(l), .remote(r)):
return l == r
default:
return false
}
}
}
struct ImagePreviewPager: View {
let images: [ImagePreviewSource]
@Binding var currentIndex: Int
let onClose: () -> Void
//
init(images: [UIImage], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
self.images = images.map { .local($0) }
self._currentIndex = currentIndex
self.onClose = onClose
}
//
init(images: [String], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
self.images = images.map { .remote($0) }
self._currentIndex = currentIndex
self.onClose = onClose
}
var body: some View {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
TabView(selection: $currentIndex) {
ForEach(Array(images.enumerated()), id: \ .element.id) { idx, source in
Group {
switch source {
case .local(let img):
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
case .remote(let urlStr):
CachedAsyncImage(url: urlStr) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
.scaleEffect(0.8)
)
}
}
}
.tag(idx)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
Button(action: { onClose() }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 32))
.foregroundColor(.white)
.padding(24)
}
}
}
}

View File

@@ -1,13 +1,28 @@
import SwiftUI
import ComposableArchitecture
//import Components // BottomTabView Components
struct MainView: View {
let store: StoreOf<MainFeature>
var onLogout: (() -> Void)? = nil
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
NavigationStack {
InternalMainView(store: store)
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
if isLoggedOut {
onLogout?()
}
}
}
}
}
// MainView InternalMainView
struct InternalMainView: View {
let store: StoreOf<MainFeature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
NavigationStack(path: viewStore.binding(get: \.navigationPath, send: MainFeature.Action.navigationPathChanged)) {
GeometryReader { geometry in
ZStack {
//
@@ -19,17 +34,18 @@ struct MainView: View {
.ignoresSafeArea(.all)
//
ZStack {
switch viewStore.selectedTab {
case .feed:
FeedListView(store: store.scope(
state: \.feedList,
action: \.feedList
))
.transition(.opacity)
case .other:
MeView(onLogout: {}) //
.transition(.opacity)
}
.isHidden(viewStore.selectedTab != .feed)
MeView(
store: store.scope(
state: \.me,
action: \.me
)
)
.isHidden(viewStore.selectedTab != .other)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
@@ -43,7 +59,39 @@ struct MainView: View {
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
}
}
.navigationDestination(for: MainFeature.Destination.self) { destination in
switch destination {
case .test:
TestPushView()
case .appSetting:
IfLetStore(
self.store.scope(
state: \.appSettingState,
action: MainFeature.Action.appSettingAction
),
then: { appSettingStore in
WithPerceptionTracking {
AppSettingView(store: appSettingStore)
}
}
)
}
}
}
.onAppear {
viewStore.send(.onAppear)
}
}
}
}
struct TestPushView: View {
var body: some View {
ZStack {
Color.blue.ignoresSafeArea()
Text("Test Push View")
.font(.largeTitle)
.foregroundColor(.white)
}
}
}

View File

@@ -1,137 +1,124 @@
import SwiftUI
import ComposableArchitecture
struct MeView: View {
@State private var showLogoutConfirmation = false
let onLogout: () -> Void //
let store: StoreOf<MeFeature>
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
//
ZStack {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
VStack(spacing: 0) {
//
HStack {
Spacer()
Text("我的")
.font(.system(size: 22, weight: .semibold))
WithViewStore(self.store, observe: { $0 }) { viewStore in
Button(action: {
viewStore.send(.settingButtonTapped)
}) {
Image(systemName: "gearshape")
.font(.system(size: 22, weight: .medium))
.foregroundColor(.white)
Spacer()
}
.padding(.top, geometry.safeAreaInsets.top + 20)
//
VStack(spacing: 16) {
Circle()
.fill(Color.white.opacity(0.2))
.frame(width: 80, height: 80)
.overlay(
Image(systemName: "person.fill")
.font(.system(size: 40))
.foregroundColor(.white)
)
Text("用户昵称")
.padding(.trailing, 16)
.padding(.top, 8)
}
}
//
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: 123456789")
Text("ID: \(userInfo.uid ?? 0)")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.top, 30)
//
VStack(spacing: 12) {
MenuItemView(icon: "gearshape", title: "设置", action: {})
MenuItemView(icon: "person.circle", title: "个人信息", action: {})
MenuItemView(icon: "heart", title: "我的收藏", action: {})
MenuItemView(icon: "clock", title: "浏览历史", action: {})
MenuItemView(icon: "questionmark.circle", title: "帮助与反馈", action: {})
.frame(height: 130)
} else {
Spacer().frame(height: 130)
}
.padding(.horizontal, 20)
.padding(.top, 40)
// 退
Button(action: {
showLogoutConfirmation = true
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.system(size: 16))
Text("退出登录")
.font(.system(size: 16, weight: .medium))
}
//
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)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
Color.white.opacity(0.1)
.cornerRadius(12)
)
Button("重试") {
viewStore.send(.onAppear)
}
.padding(.horizontal, 20)
.padding(.top, 30)
// -
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
}
.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 {
LazyVStack(spacing: 12) {
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
.padding(.horizontal, 12)
}
if viewStore.hasMore {
ProgressView()
.onAppear {
viewStore.send(.loadMore)
}
}
}
.ignoresSafeArea(.container, edges: .top)
.alert("确认退出", isPresented: $showLogoutConfirmation) {
Button("取消", role: .cancel) { }
Button("退出", role: .destructive) {
Task { await performLogout() }
.padding(.top, 8)
}
} message: {
Text("确定要退出登录吗?")
.refreshable {
viewStore.send(.refresh)
}
}
// MARK: - 退
private func performLogout() async {
debugInfoSync("🔓 开始执行退出登录...")
// keychain
await UserInfoManager.clearAllAuthenticationData()
//
onLogout()
debugInfoSync("✅ 退出登录完成")
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
}
}
// MARK: -
struct MenuItemView: View {
let icon: String
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(.white)
.frame(width: 24)
Text(title)
.font(.system(size: 16))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.6))
}
.padding(.horizontal, 20)
.frame(height: 56)
.background(
Color.white.opacity(0.1)
.cornerRadius(12)
)
}
.buttonStyle(PlainButtonStyle())
.onAppear {
ViewStore(self.store, observe: { $0 }).send(.onAppear)
}
}
#Preview {
MeView(onLogout: {})
}

View File

@@ -1,199 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct SettingView: View {
let store: StoreOf<SettingFeature>
@ObservedObject private var localizationManager = LocalizationManager.shared
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
// - 使"bg"
Image("bg")
.resizable()
.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("设置")
.font(.custom("PingFang SC-Semibold", size: 16))
.foregroundColor(.white)
Spacer()
//
Color.clear
.frame(width: 44, height: 44)
}
.padding(.top, 8)
.padding(.horizontal)
//
ScrollView {
VStack(spacing: 24) {
//
VStack(spacing: 16) {
//
Image(systemName: "person.circle.fill")
.font(.system(size: 60))
.foregroundColor(.white.opacity(0.8))
//
VStack(spacing: 8) {
if let userInfo = store.userInfo, let userName = userInfo.username {
Text(userName)
.font(.title2.weight(.semibold))
.foregroundColor(.white)
} else {
Text("用户")
.font(.title2.weight(.semibold))
.foregroundColor(.white)
}
// ID
if let userInfo = store.userInfo, let userId = userInfo.userId {
Text("ID: \(userId)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
} else if let accountModel = store.accountModel, let uid = accountModel.uid {
Text("UID: \(uid)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
}
}
.padding(.vertical, 24)
.padding(.horizontal, 20)
.background(Color.black.opacity(0.3))
.cornerRadius(16)
.padding(.horizontal, 24)
.padding(.top, 32)
//
VStack(spacing: 12) {
//
SettingRowView(
icon: "globe",
title: "语言设置",
action: {
// TODO:
}
)
//
SettingRowView(
icon: "info.circle",
title: "关于我们",
action: {
// TODO:
}
)
//
HStack {
Image(systemName: "app.badge")
.foregroundColor(.white.opacity(0.8))
.frame(width: 24)
Text("版本信息")
.foregroundColor(.white)
Spacer()
Text("1.0.0")
.foregroundColor(.white.opacity(0.6))
.font(.caption)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(Color.black.opacity(0.2))
.cornerRadius(12)
}
.padding(.horizontal, 24)
Spacer(minLength: 50)
// 退
Button(action: {
store.send(.logoutTapped)
}) {
HStack {
Image(systemName: "arrow.right.square")
Text("退出登录")
}
.font(.body.weight(.medium))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.red.opacity(0.7))
.cornerRadius(12)
}
.padding(.horizontal, 24)
.padding(.bottom, 50)
}
}
}
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
// MARK: - Setting Row View
struct SettingRowView: View {
let icon: String
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Image(systemName: icon)
.foregroundColor(.white.opacity(0.8))
.frame(width: 24)
Text(title)
.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)
}
}
}
//#Preview {
// SettingView(
// store: Store(
// initialState: SettingFeature.State()
// ) {
// SettingFeature()
// }
// )
//}

View File

@@ -30,6 +30,9 @@ struct SplashView: View {
initialState: MainFeature.State()
) {
MainFeature()
},
onLogout: {
store.send(.navigateToLogin)
}
)
}
@@ -64,7 +67,7 @@ struct SplashView: View {
.frame(width: 100, height: 100)
// - 40pt
Text("E-Parti")
Text(NSLocalizedString("splash.title", comment: "E-Parti"))
.font(.system(size: 40, weight: .regular))
.foregroundColor(.white)

View File

@@ -173,3 +173,68 @@ final class yanaAPITests: XCTestCase {
}
}
}
// MARK: - User Info API Tests
extension yanaAPITests {
func testGetUserInfoRequest() {
//
let uid = "12345"
let request = UserInfoHelper.createGetUserInfoRequest(uid: uid)
XCTAssertEqual(request.endpoint, "/user/get", "端点应该正确")
XCTAssertEqual(request.method, .GET, "请求方法应该是GET")
XCTAssertEqual(request.queryParameters?["uid"], uid, "UID参数应该正确")
XCTAssertFalse(request.shouldShowLoading, "不应该显示loading")
XCTAssertFalse(request.shouldShowError, "不应该显示错误")
}
func testGetUserInfoResponse() {
//
let responseData: [String: Any] = [
"code": 200,
"message": "success",
"timestamp": 1640995200000,
"data": [
"user_id": "12345",
"username": "testuser",
"nickname": "测试用户",
"avatar": "https://example.com/avatar.jpg",
"email": "test@example.com",
"phone": "13800138000",
"status": "active",
"create_time": "2024-01-01 00:00:00",
"update_time": "2024-01-01 00:00:00"
]
]
do {
let jsonData = try JSONSerialization.data(withJSONObject: responseData)
let response = try JSONDecoder().decode(GetUserInfoResponse.self, from: jsonData)
XCTAssertTrue(response.isSuccess, "响应应该成功")
XCTAssertEqual(response.code, 200, "状态码应该正确")
XCTAssertNotNil(response.data, "用户信息数据应该存在")
if let userInfo = response.data {
XCTAssertEqual(userInfo.userId, "12345", "用户ID应该正确")
XCTAssertEqual(userInfo.username, "testuser", "用户名应该正确")
XCTAssertEqual(userInfo.nickname, "测试用户", "昵称应该正确")
}
debugInfoSync("✅ 用户信息响应解析测试通过")
} catch {
XCTFail("解析用户信息响应失败: \(error)")
}
}
func testUserInfoHelper() {
// UserInfoHelper
let uid = "67890"
UserInfoHelper.debugGetUserInfoRequest(uid: uid)
let request = UserInfoHelper.createGetUserInfoRequest(uid: uid)
XCTAssertEqual(request.queryParameters?["uid"], uid, "Helper创建的请求应该正确")
}
}

View File

@@ -175,11 +175,13 @@ xcodebuild -workspace yana.xcworkspace -scheme yana -configuration Debug build
```
### 关键代码修改
1. **HomeFeature.swift**: 添加设置相关状态管理
2. **HomeView.swift**: 修复 TCA store 绑定语法
3. **SettingFeature.swift**: 确保 Action 完整性
### 构建结果
**编译成功**: Exit code 0
⚠️ **警告信息**: 仅 Swift 6 兼容性警告,不影响运行
@@ -188,18 +190,21 @@ xcodebuild -workspace yana.xcworkspace -scheme yana -configuration Debug build
## 预防措施
### 开发规范
1. **统一包管理**: 优先使用一种包管理工具
2. **定期清理**: 定期清理 DerivedData 避免缓存问题
3. **代码审查**: 确保 TCA Feature 结构完整
4. **版本控制**: 及时提交关键配置文件
### 监控指标
- [ ] 项目编译时间 < 30s
- [ ] 无编译错误
- [ ] 依赖解析正常
- [ ] TCA 结构完整
### 工具使用
```bash
# 项目健康检查脚本
check_project() {
@@ -255,6 +260,7 @@ pod install --clean-install
## 总结
本次问题解决涉及以下关键技术点
1. **Xcode 项目配置管理**
2. **Swift Package Manager 与 CocoaPods 共存**
3. **TCA (The Composable Architecture) 最佳实践**
@@ -265,5 +271,5 @@ pod install --clean-install
---
**文档更新时间**: 2025-07-10
**适用版本**: iOS 15.6+, Swift 6, TCA 1.20.2+
**适用版本**: iOS 16+, Swift 6, TCA 1.20.2+
**维护者**: AI Assistant & 开发团队