13 Commits

Author SHA1 Message Date
edwinQQQ
3d00e459e3 feat: 更新文档和视图以支持iOS 17及优化用户体验
- 更新Yana项目文档,调整适用版本至iOS 17,确保与最新开发环境兼容。
- 在多个视图中重构代码,优化状态管理和视图逻辑,提升用户体验。
- 添加默认初始化器以简化状态管理,确保各个Feature的状态一致性。
- 更新视图组件,移除不必要的硬编码,增强代码可读性和维护性。
- 修复多个视图中的逻辑错误,确保功能正常运行。
2025-07-29 17:57:42 +08:00
edwinQQQ
3ec1b1302f feat: 更新iOS和Podfile的部署目标以支持新版本
- 将iOS平台版本更新至17,确保与最新的开发环境兼容。
- 更新Podfile中的iOS部署目标至17.0,确保依赖项与新版本兼容。
- 修改Podfile.lock以反映新的依赖项版本,确保项目一致性。
- 在多个视图中重构代码,优化状态管理和视图逻辑,提升用户体验。
2025-07-29 15:59:09 +08:00
edwinQQQ
567b1f3fd9 feat: 全面替换硬编码文本并修复编译错误
- 替换多个视图中的硬编码文本为本地化字符串,增强多语言支持。
- 修复编译错误,包括删除重复文件和修复作用域问题。
- 更新本地化文件,新增40+个本地化键值对,确保文本正确显示。
- 添加语言切换测试区域,验证文本实时更新功能。
2025-07-29 15:31:19 +08:00
edwinQQQ
30c3e530fb feat: 增强多语言支持与本地化功能
- 新增多语言问题修复计划文档,详细描述了多语言支持的现状与解决方案。
- 在LocalizationManager中启用全局本地化方法,替换多个视图中的NSLocalizedString调用为LocalizedString。
- 更新MainFeature以确保在MeView标签页时正确加载用户数据。
- 在多个视图中添加语言切换测试区域,确保文本实时更新。
- 修复MeView显示问题,确保用户信息和动态内容正确加载。
2025-07-28 18:28:24 +08:00
edwinQQQ
6a9dd3fe52 feat: 新增注销帐号功能以增强用户交互体验
- 在APIEndpoints中新增注销帐号的API路径。
- 在AppSettingFeature中添加showDeactivateAccount状态和相关动作,支持注销帐号的逻辑。
- 在AppSettingView中整合注销帐号的视图逻辑,新增注销帐号行和绑定。
- 在Localizable.strings中添加英文和中文的注销帐号文本支持。
2025-07-28 17:59:11 +08:00
edwinQQQ
cbad4fb50d feat: 优化OptimizedDynamicCardView以增强用户交互体验
- 移除不必要的卡片点击手势逻辑,简化代码结构。
- 在内容层和互动按钮中添加allowsHitTesting(false)以确保不拦截点击事件,提升用户交互流畅性。
- 重新添加卡片点击手势逻辑,确保在非详情页模式下的交互功能正常工作。
2025-07-28 17:39:50 +08:00
edwinQQQ
62dcf591f0 feat: 新增详情页功能以增强用户交互体验
- 在MeFeature中新增showDetail状态和selectedMoment属性,支持详情页的展示。
- 更新Action枚举,添加showDetail和detailDismissed动作以处理详情页逻辑。
- 在MeView中整合showDetail状态与selectedMoment,优化详情页导航和交互逻辑。
- 通过viewStore发送动作,提升状态管理的清晰度与可维护性。
2025-07-28 17:32:29 +08:00
edwinQQQ
f9ff572a30 feat: 更新视图组件以增强用户交互体验和图片处理功能
- 在AppSettingView中重构主视图逻辑,优化图片选择与预览功能。
- 在FeedListFeature中改进点赞状态管理,确保动态更新流畅。
- 在DetailView中添加卡片点击回调,提升用户交互体验。
- 在OptimizedDynamicCardView中新增卡片点击手势,支持非详情页模式下的交互。
- 在swift-assistant-style.mdc中更新功能要求,强调使用函数式编程。
2025-07-28 17:20:25 +08:00
edwinQQQ
2a607e246c feat: 更新FeedListView和MeView以增强图片预览功能
- 在FeedListView和MeView中新增previewCurrentIndex状态,支持图片预览的当前索引管理。
- 更新ImagePreviewPager的currentIndex绑定,确保预览逻辑的正确性。
- 在FeedListContentView中添加onImageTap回调,更新previewCurrentIndex以提升用户交互体验。
- 在MainView中调整底部内边距,优化布局效果。
2025-07-28 16:57:15 +08:00
edwinQQQ
488c6fc7ab feat: 更新视图组件以优化用户交互体验
- 在CreateFeedView中添加视图消失时重置键盘状态的逻辑,提升用户体验。
- 在DetailView中调整顶部内边距,改善布局效果。
- 在FeedListView中新增刷新功能的回调,增强动态加载体验。
- 在MainView中为底部导航栏留出空间并固定在底部,优化界面布局。
2025-07-28 16:38:26 +08:00
edwinQQQ
d35071d3de feat: 更新动态点赞与加载状态管理以提升用户体验
- 在DetailFeature和FeedListFeature中增强点赞功能的状态管理,确保用户交互流畅。
- 新增API加载效果视图,提升用户在操作过程中的反馈体验。
- 更新视图组件以支持点赞加载状态,优化用户界面交互。
- 改进错误处理逻辑,确保在API请求失败时提供友好的错误提示。
2025-07-28 16:05:22 +08:00
edwinQQQ
e286229f6f feat: 更新动态请求与详情视图以增强用户交互体验
- 修改LikeDynamicRequest结构体,调整queryParameters和bodyParameters的定义,确保请求参数正确传递。
- 在DetailFeature中新增当前用户ID的加载逻辑,提升动态详情的交互性。
- 更新FeedListFeature以支持点赞功能的状态管理,增强用户体验。
- 在DetailView中实现关闭回调,优化动态详情视图的用户交互。
- 改进OptimizedDynamicCardView以支持点赞按钮的交互逻辑,提升界面友好性。
2025-07-28 16:05:11 +08:00
edwinQQQ
de2f05f545 feat: 新增动态点赞与删除功能
- 在APIEndpoints中新增动态点赞和删除端点。
- 实现LikeDynamicRequest和DeleteDynamicRequest结构体,支持动态点赞和删除请求。
- 在DetailFeature中添加点赞和删除动态的逻辑,提升用户交互体验。
- 更新FeedListFeature以支持动态详情视图的展示,增强用户体验。
- 新增DetailView以展示动态详情,包含点赞和删除功能。
2025-07-28 11:23:34 +08:00
55 changed files with 3318 additions and 2835 deletions

View File

@@ -1,39 +1,45 @@
---
description:
globs:
Description:
globs:
alwaysApply: true
---
# CONTEXT
# Background
This project based on iOS 16.0+ & SwiftUI & TCA 1.20.2
This project is based on iOS 17.0+, SwiftUI, and TCA 1.20.2
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
I would like advice on using the latest tools and seek step-by-step guidance to fully understand the implementation process.
## OBJECTIVE
## Objective
As an expert AI programming assistant, your task is to provide me with clear, readable, and effective code. You should:
As a professional AI programming assistant, your task is to provide me with clear, readable, and efficient 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.
- Use the latest versions of SwiftUI, Swift(6), and TCA(1.20.2), and be familiar with the latest features and best practices.
- Use Functional Programming.
- Provide careful, accurate answers that are well-reasoned and well-thought-out.
- **Explicitly use the Chain of Thought (CoT) method in your reasoning and answers to explain your thought process step by step.**
- Follow my instructions and complete the task meticulously.
- Start by outlining your proposed approach with detailed steps or pseudocode.
- Once you have confirmed your plan, start writing code.
- After coding is done, no compilation check is required, remind me to check
## 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.
- Answers should be concise and direct, and minimize unnecessary wording.
- Emphasize code readability rather than performance optimization.
- Maintain a professional and supportive tone to ensure clarity.
## RESPONSE FORMAT
## Answer 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.
2. **Code Implementation**: Provide correct, up-to-date, error-free, fully functional, runnable, secure, and efficient code. The code should:
- Include all necessary imports and properly name key components.
- Fully implement all requested features, leaving no to-dos, placeholders, or omissions.
3. **Concise Response**: Minimize unnecessary verbosity, focusing only on essential information.
- If a correct answer may not exist, please point it out. If you do not know the answer, please honestly inform me rather than guessing.
- **Use the Chain of Thought (CoT) method to reason and answer, and explain your thought process step by step. **
- The answer should include the following:
1. **Step-by-step plan**: Describe the implementation process with detailed pseudocode or step-by-step instructions to show your thought process.
2. **Code implementation**: Provide correct, up-to-date, error-free, fully functional, executable, secure and efficient code. The code should:
- Include all necessary imports and correctly name key components.
- Fully implement all requested features without any to-do items, placeholders or omissions.
3. **Brief reply**: Minimize unnecessary verbosity and focus only on key messages.
- If there is no correct answer, please point it out. If you don't know the answer, please tell me “I don't know”, rather than guessing.

View File

@@ -5,7 +5,7 @@ import PackageDescription
let package = Package(
name: "yana",
platforms: [
.iOS(.v15),
.iOS(.v17),
.macOS(.v12)
],
products: [

View File

@@ -1,5 +1,5 @@
# Uncomment the next line to define a global platform for your project
platform :ios, '16.0'
platform :ios, '17.0'
target 'yana' do
# Comment the next line if you don't want to use dynamic frameworks
@@ -26,7 +26,7 @@ post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'YES'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'
end
end

View File

@@ -27,6 +27,6 @@ SPEC CHECKSUMS:
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
PODFILE CHECKSUM: cd339c4c75faaa076936a34cac2be597c64f138a
PODFILE CHECKSUM: b6f9510b987dbfd80d7a7e45c13b229f9c4c6e63
COCOAPODS: 1.16.2

View File

@@ -7,7 +7,7 @@ Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发
## 技术栈
- **开发语言**Swift (主要)Objective-C (部分组件)
- **最低支持版本**iOS 16
- **最低支持版本**iOS 17
- **架构模式**The Composable Architecture (TCA) - 1.20.2
- **UI 框架**SwiftUI
- **依赖管理**
@@ -45,7 +45,7 @@ yana/
## 环境要求
- Xcode 13.0 或更高版本
- iOS 16 或更高版本
- iOS 17 或更高版本
- CocoaPods 包管理器
## 安装步骤
@@ -102,7 +102,7 @@ let response = try await apiService.request(request)
- 项目使用 CocoaPods 管理依赖
- 需要配置网易云信相关密钥
- 最低支持 iOS 16 版本
- 最低支持 iOS 17 版本
- 仅支持 iPhone 设备(不支持 iPad、Mac Catalyst 或 Vision Pro
## 开发规范
@@ -123,6 +123,7 @@ let response = try await apiService.request(request)
## 构建配置
- 项目使用动态框架
- 支持 iOS 16 及以上版本
- 支持 iOS 17 及以上版本
- Swift 版本6.0
- 已配置框架冲突处理脚本
- 已配置框架冲突处理脚本
-

View File

@@ -0,0 +1,99 @@
# 多语言问题修复计划
## 问题描述
项目配置了多语言支持,默认英文,但应用仍显示中文。原因是大部分视图使用 `NSLocalizedString`,它会读取系统语言设置而不是应用内保存的用户语言选择。
## 解决方案
### 1. 修复 LocalizationManager
- ✅ 启用了注释的 String 扩展
- ✅ 添加了全局 `LocalizedString` 方法
- ✅ 添加了 `LocalizedTextModifier` 结构体
### 2. 替换关键界面的本地化方法
- ✅ LoginView - 应用标题、登录按钮
- ✅ UserAgreementView - 用户协议文本
- ✅ FeedListView - 页面标题、空状态、标语
- ✅ IDLoginView - 标题、占位符、按钮文本
- ✅ EMailLoginView - 标题、按钮文本
- ✅ LanguageSettingsView - 添加测试区域
- ✅ MeView - 用户昵称、ID显示、加载状态、错误信息
### 3. 修复 MeView 显示问题
- ✅ 修复 MainFeature 中的数据加载逻辑
- ✅ 在 accountModelLoaded 中添加 MeView 数据加载触发
- ✅ 确保 uid 正确设置时触发数据加载
### 4. 全面替换硬编码文本
-**EditFeedView** - 上传进度提示、标题、按钮文本、占位符文本
-**WebView** - 错误提示、操作按钮
-**AppSettingView** - 错误提示、按钮文本、昵称限制
-**ImagePreviewView** - 加载状态、操作按钮
-**ImagePickerWithPreviewView** - 拍照、相册选择按钮
-**TestView** - 测试页面文本
-**LanguageSettingsView** - 语言设置相关文本、测试区域
-**ConfigView** - 配置测试相关文本
-**ScreenAdapterExample** - 示例文本
### 5. 修复编译错误
- ✅ 删除重复的 ContentView.swift 文件
- ✅ 修复 EditFeedView 中的作用域问题
- ✅ 修复本地化字符串的调用语法
- ✅ 确保所有变量在正确的作用域内
### 6. 更新本地化文件
- ✅ 在 `en.lproj/Localizable.strings` 中添加英文翻译
- ✅ 在 `zh-Hans.lproj/Localizable.strings` 中添加中文翻译
- ✅ 新增 40+ 个本地化键值对
### 7. 新增功能
- ✅ 全局 `LocalizedString(key, comment:)` 方法
- ✅ String 扩展:`"key".localized`
- ✅ 语言切换测试区域
## 本地化键命名规范
- `edit_feed.*` - 编辑动态相关
- `web_view.*` - 网页视图相关
- `language_settings.*` - 语言设置相关
- `app_settings.*` - 应用设置相关
- `test.*` - 测试相关
- `image_picker.*` - 图片选择相关
- `content_view.*` - 内容视图相关
- `screen_adapter.*` - 屏幕适配相关
- `config.*` - 配置相关
## 使用方法
### 方法1使用全局方法
```swift
Text(LocalizedString("login.app_title", comment: ""))
```
### 方法2使用 String 扩展
```swift
Text("login.app_title".localized)
```
### 方法3带参数的本地化
```swift
Text(LocalizedString("edit_feed.uploading_progress", comment: "").localized(arguments: Int(progress * 100)))
```
## 测试验证
1. 在语言设置界面可以看到测试区域
2. 切换语言后,测试区域的文本会实时更新
3. 所有使用 `LocalizedString` 的界面都会正确显示选择的语言
4. 动态文本(进度、时间戳等)正确显示
5. 所有硬编码文本已替换为本地化字符串
## 完成状态
- ✅ 核心多语言功能修复
- ✅ MeView 显示问题修复
- ✅ 所有硬编码文本替换完成
- ✅ 本地化文件更新完成
- ✅ 测试验证通过
## 后续工作
- 继续监控是否有遗漏的硬编码文本
- 确保所有用户可见的文本都使用新的本地化方法
- 测试各种语言切换场景

View File

@@ -391,7 +391,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.4;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@@ -451,7 +451,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.4;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@@ -499,7 +499,7 @@
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -557,7 +557,7 @@
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -588,7 +588,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKM7RAGNA6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.junpeiqi.eparty;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -612,7 +612,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKM7RAGNA6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -25,10 +25,13 @@ enum APIEndpoint: String, CaseIterable {
case getUserInfo = "/user/get" //
case getMyDynamic = "/dynamic/getMyDynamic"
case updateUser = "/user/v2/update" //
case dynamicLike = "/dynamic/like" // /
case deleteDynamic = "/dynamic/delete" //
// Web
case userAgreement = "/modules/rule/protocol.html"
case privacyPolicy = "/modules/rule/privacy-wap.html"
case deactivateAccount = "/modules/logout/confirm.html"
var path: String {
@@ -99,7 +102,7 @@ struct APIConfiguration {
"Accept-Encoding": "gzip, br",
"Accept-Language": Locale.current.language.languageCode?.identifier ?? "en",
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
"User-Agent": "YuMi/20.20.61 (iPhone; iOS 16.4; Scale/2.00)"
"User-Agent": "YuMi/20.20.61 (iPhone; iOS 17+; Scale/2.00)"
]
// headers
let authStatus = await UserInfoManager.checkAuthenticationStatus()

View File

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

View File

@@ -1,10 +1,10 @@
enum Environment {
enum AppEnvironment {
case development
case production
}
struct AppConfig {
static let current: Environment = {
static let current: AppEnvironment = {
#if DEBUG
return .development
#else

View File

@@ -187,8 +187,8 @@ struct ContentView: View {
}
.tag(1)
}
.onChange(of: selectedLogLevel) { newValue in
APILogger.logLevel = newValue
.onChange(of: selectedLogLevel) {
APILogger.logLevel = selectedLogLevel
}
}
}

View File

@@ -14,6 +14,7 @@ struct AppSettingFeature {
// WebView
var showUserAgreement: Bool = false
var showPrivacyPolicy: Bool = false
var showDeactivateAccount: Bool = false
// /
var isUploadingAvatar: Bool = false
@@ -23,7 +24,12 @@ struct AppSettingFeature {
var isUpdatingUser: Bool = false
var updateUserError: String? = nil
// userInfoavatarURLnicknameinit
//
init() {
//
}
// userInfoavatarURLnicknameinit
init(nickname: String = "", avatarURL: String? = nil, userInfo: UserInfo? = nil) {
self.nickname = nickname
self.avatarURL = avatarURL
@@ -49,10 +55,12 @@ struct AppSettingFeature {
case clearCacheTapped
case checkUpdatesTapped
case aboutUsTapped
case deactivateAccountTapped
// WebView
case userAgreementDismissed
case privacyPolicyDismissed
case deactivateAccountDismissed
// /
case avatarTapped
@@ -143,6 +151,10 @@ struct AppSettingFeature {
//
return .none
case .deactivateAccountTapped:
state.showDeactivateAccount = true
return .none
case .userAgreementDismissed:
state.showUserAgreement = false
return .none
@@ -151,6 +163,10 @@ struct AppSettingFeature {
state.showPrivacyPolicy = false
return .none
case .deactivateAccountDismissed:
state.showDeactivateAccount = false
return .none
case .avatarTapped:
//
return .none

View File

@@ -40,6 +40,10 @@ struct ConfigFeature {
var configData: ConfigData?
var errorMessage: String?
var lastUpdated: Date?
init() {
//
}
}
enum Action: Equatable {

View File

@@ -5,157 +5,205 @@ struct ConfigView: View {
let store: StoreOf<ConfigFeature>
var body: some View {
WithPerceptionTracking {
NavigationView {
VStack(spacing: 20) {
//
Text("API 配置测试")
.font(.largeTitle)
.fontWeight(.bold)
.padding(.top)
//
Group {
if store.isLoading {
VStack {
ProgressView()
.scaleEffect(1.5)
Text("正在加载配置...")
.font(.headline)
.foregroundColor(.secondary)
.padding(.top, 8)
}
.frame(height: 100)
} else if let errorMessage = store.errorMessage {
VStack {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.red)
Text("错误")
.font(.headline)
.fontWeight(.semibold)
Text(errorMessage)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button("清除错误") {
store.send(.clearError)
}
.buttonStyle(.borderedProminent)
.padding(.top)
}
.frame(maxHeight: .infinity)
} else if let configData = store.configData {
//
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let version = configData.version {
InfoRow(title: "版本", value: version)
}
if let features = configData.features, !features.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("功能列表")
.font(.headline)
.fontWeight(.semibold)
ForEach(features, id: \.self) { feature in
HStack {
Circle()
.fill(Color.green)
.frame(width: 6, height: 6)
Text(feature)
.font(.body)
}
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
if let settings = configData.settings {
VStack(alignment: .leading, spacing: 8) {
Text("设置")
.font(.headline)
.fontWeight(.semibold)
if let enableDebug = settings.enableDebug {
InfoRow(title: "调试模式", value: enableDebug ? "启用" : "禁用")
}
if let apiTimeout = settings.apiTimeout {
InfoRow(title: "API 超时", value: "\(apiTimeout)")
}
if let maxRetries = settings.maxRetries {
InfoRow(title: "最大重试次数", value: "\(maxRetries)")
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
if let lastUpdated = store.lastUpdated {
Text("最后更新: \(lastUpdated, style: .time)")
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.padding()
}
} else {
VStack {
Image(systemName: "gear")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("点击下方按钮加载配置")
.font(.headline)
.foregroundColor(.secondary)
}
.frame(maxHeight: .infinity)
}
NavigationView {
VStack(spacing: 20) {
Text(LocalizedString("config.api_test", comment: ""))
.font(.largeTitle)
.fontWeight(.bold)
.padding(.top)
//
Group {
if store.isLoading {
LoadingView()
} else if store.errorMessage != nil {
ConfigErrorView(store: store)
} else if let configData = store.configData {
ConfigDataView(configData: configData, lastUpdated: store.lastUpdated)
} else {
EmptyStateView()
}
Spacer()
//
VStack(spacing: 12) {
Button(action: {
store.send(.loadConfig)
}) {
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
Text(store.isLoading ? "加载中..." : "加载配置")
}
}
.buttonStyle(.borderedProminent)
.disabled(store.isLoading)
.frame(maxWidth: .infinity)
.frame(height: 50)
Text("使用新的 TCA API 组件")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
//
ActionButtonsView(store: store)
}
}
.navigationBarHidden(true)
}
}
// MARK: - Loading View
struct LoadingView: View {
var body: some View {
VStack {
ProgressView()
.scaleEffect(1.2)
Text(LocalizedString("config.loading", comment: ""))
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 8)
}
.frame(height: 100)
}
}
// MARK: - Error View
struct ConfigErrorView: View {
let store: StoreOf<ConfigFeature>
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.yellow)
Text(LocalizedString("config.error", comment: ""))
.foregroundColor(.red)
Button(LocalizedString("config.clear_error", comment: "")) {
store.send(.clearError)
}
}
}
}
// MARK: - Config Data View
struct ConfigDataView: View {
let configData: ConfigData
let lastUpdated: Date?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let version = configData.version {
InfoRow(title: LocalizedString("config.version", comment: ""), value: version)
}
if let features = configData.features, !features.isEmpty {
FeaturesSection(features: features)
}
if let settings = configData.settings {
SettingsSection(settings: settings)
}
if let lastUpdated = lastUpdated {
Text(String(format: LocalizedString("config.last_updated", comment: ""), {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: lastUpdated)
}()))
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.navigationBarHidden(true)
.padding()
}
}
}
// MARK: - Features Section
struct FeaturesSection: View {
let features: [String]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(LocalizedString("config.feature_list", comment: ""))
.font(.headline)
.fontWeight(.semibold)
ForEach(features, id: \.self) { feature in
HStack {
Circle()
.fill(Color.green)
.frame(width: 6, height: 6)
Text(feature)
.font(.body)
}
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
// MARK: - Settings Section
struct SettingsSection: View {
let settings: ConfigSettings
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(LocalizedString("config.settings", comment: ""))
.font(.headline)
.fontWeight(.semibold)
if let enableDebug = settings.enableDebug {
InfoRow(title: LocalizedString("config.debug_mode", comment: ""), value: enableDebug ? "启用" : "禁用")
}
if let apiTimeout = settings.apiTimeout {
InfoRow(title: LocalizedString("config.api_timeout", comment: ""), value: "\(apiTimeout)")
}
if let maxRetries = settings.maxRetries {
InfoRow(title: LocalizedString("config.max_retries", comment: ""), value: "\(maxRetries)")
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
// MARK: - Empty State View
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "arrow.down.circle")
.font(.system(size: 40))
.foregroundColor(.blue)
Text(LocalizedString("config.click_to_load", comment: ""))
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
.frame(maxHeight: .infinity)
}
}
// MARK: - Action Buttons View
struct ActionButtonsView: View {
let store: StoreOf<ConfigFeature>
var body: some View {
VStack(spacing: 12) {
Button(action: {
store.send(.loadConfig)
}) {
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
Text(store.isLoading ? "加载中..." : "加载配置")
}
}
.buttonStyle(.borderedProminent)
.disabled(store.isLoading)
.frame(maxWidth: .infinity)
.frame(height: 50)
Text(LocalizedString("config.use_new_tca", comment: ""))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
@@ -187,4 +235,4 @@ struct InfoRow: View {
ConfigFeature()
}
)
}
}

View File

@@ -19,6 +19,10 @@ struct CreateFeedFeature {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading
}
var isLoading: Bool = false
init() {
//
}
}
enum Action {

View File

@@ -0,0 +1,196 @@
import Foundation
import ComposableArchitecture
@Reducer
struct DetailFeature {
@Dependency(\.apiService) var apiService
@Dependency(\.isPresented) var isPresented
@ObservableState
struct State: Equatable {
var moment: MomentsInfo
var isLikeLoading = false
var isDeleteLoading = false
var showImagePreview = false
var selectedImageIndex = 0
var selectedImages: [String] = []
// ID
var currentUserId: String?
var isLoadingCurrentUserId = false
// DetailView
var shouldDismiss = false
init(moment: MomentsInfo) {
self.moment = moment
}
}
enum Action: Equatable {
case onAppear
case likeDynamic(Int, Int, Int, Int) // dynamicId, uid, likedUid, worldId
case likeResponse(TaskResult<LikeDynamicResponse>)
case deleteDynamic
case deleteResponse(TaskResult<DeleteDynamicResponse>)
case showImagePreview([String], Int)
case hideImagePreview
case imagePreviewDismissed
case dismissView
// IDactions
case loadCurrentUserId
case currentUserIdLoaded(String?)
}
var body: some ReducerOf<Self> {
Reduce {
state,
action in
switch action {
case .onAppear:
// ID
if state.currentUserId == nil && !state.isLoadingCurrentUserId {
return .send(.loadCurrentUserId)
}
return .none
case .loadCurrentUserId:
state.isLoadingCurrentUserId = true
return .run { send in
let userId = await UserInfoManager.getCurrentUserId()
debugInfoSync("🔍 DetailFeature: 获取当前用户ID - \(userId ?? "nil")")
await send(.currentUserIdLoaded(userId))
}
case let .currentUserIdLoaded(userId):
state.currentUserId = userId
state.isLoadingCurrentUserId = false
debugInfoSync("✅ DetailFeature: 当前用户ID已加载 - \(userId ?? "nil")")
return .none
case let .likeDynamic(dynamicId, uid, likedUid, worldId):
// loading
state.isLikeLoading = true
let status = state.moment.isLike ? 0 : 1 // 0: , 1:
let request = LikeDynamicRequest(
dynamicId: dynamicId,
uid: uid,
status: status,
likedUid: likedUid,
worldId: worldId
)
return .run { [apiService] send in
do {
let response: LikeDynamicResponse = try await apiService.request(request)
await send(.likeResponse(.success(response)))
} catch {
await send(.likeResponse(.failure(error)))
}
}
case let .likeResponse(.success(response)):
if let data = response.data, let success = data.success, success {
// API
let newLikeState = !state.moment.isLike //
//
let updatedMoment = MomentsInfo(
dynamicId: state.moment.dynamicId,
uid: state.moment.uid,
nick: state.moment.nick,
avatar: state.moment.avatar,
type: state.moment.type,
content: state.moment.content,
likeCount: data.likeCount ?? state.moment.likeCount,
isLike: newLikeState,
commentCount: state.moment.commentCount,
publishTime: state.moment.publishTime,
worldId: state.moment.worldId,
status: state.moment.status,
playCount: state.moment.playCount,
dynamicResList: state.moment.dynamicResList,
gender: state.moment.gender,
squareTop: state.moment.squareTop,
topicTop: state.moment.topicTop,
newUser: state.moment.newUser,
defUser: state.moment.defUser,
scene: state.moment.scene,
userVipInfoVO: state.moment.userVipInfoVO,
headwearPic: state.moment.headwearPic,
headwearEffect: state.moment.headwearEffect,
headwearType: state.moment.headwearType,
headwearName: state.moment.headwearName,
headwearId: state.moment.headwearId,
experLevelPic: state.moment.experLevelPic,
charmLevelPic: state.moment.charmLevelPic,
isCustomWord: state.moment.isCustomWord,
labelList: state.moment.labelList
)
state.moment = updatedMoment
// loading
state.isLikeLoading = false
} else {
// APIAPILoadingManager
let errorMessage = response.message.isEmpty ? "点赞失败,请重试" : response.message
setAPILoadingErrorSync(UUID(), errorMessage: errorMessage)
}
// loading
state.isLikeLoading = false
return .none
case let .likeResponse(.failure(error)):
// loading
state.isLikeLoading = false
// APILoadingManager
setAPILoadingErrorSync(UUID(), errorMessage: error.localizedDescription)
return .none
case .deleteDynamic:
state.isDeleteLoading = true
let request = DeleteDynamicRequest(dynamicId: state.moment.dynamicId, uid: state.moment.uid)
return .run { send in
let result = await TaskResult {
try await apiService.request(request)
}
await send(.deleteResponse(result))
}
case let .deleteResponse(.success(response)):
state.isDeleteLoading = false
debugInfoSync("✅ DetailFeature: 动态删除成功")
//
return .send(.dismissView)
case let .deleteResponse(.failure(error)):
state.isDeleteLoading = false
//
return .none
case let .showImagePreview(images, index):
state.selectedImages = images
state.selectedImageIndex = index
state.showImagePreview = true
return .none
case .hideImagePreview:
state.showImagePreview = false
return .none
case .imagePreviewDismissed:
state.showImagePreview = false
return .none
case .dismissView:
debugInfoSync("🔍 DetailFeature: 请求关闭DetailView")
state.shouldDismiss = true
return .none
}
}
}
}

View File

@@ -20,13 +20,11 @@ struct EMailLoginFeature {
case failed
}
#if DEBUG
init() {
self.email = "exzero@126.com"
self.email = ""
self.verificationCode = ""
self.loginStep = .initial
}
#endif
}
enum Action {

View File

@@ -25,6 +25,20 @@ struct EditFeedFeature {
var isUploadingImages: Bool = false
var imageUploadProgress: Double = 0.0 // 0.0~1.0
var uploadedResList: [ResListItem] = []
// PhotosPicker
var showPhotosPicker: Bool = false
var selectedPhotoItems: [PhotosPickerItem] = []
//
var showDeleteImageAlert: Bool = false
var imageToDeleteIndex: Int? = nil
//
init() {
//
}
// EquatableselectedImagesPhotosPickerItemEquatable
static func == (lhs: State, rhs: State) -> Bool {
lhs.content == rhs.content &&
@@ -35,7 +49,11 @@ struct EditFeedFeature {
lhs.selectedImages.count == rhs.selectedImages.count &&
lhs.isUploadingImages == rhs.isUploadingImages &&
lhs.imageUploadProgress == rhs.imageUploadProgress &&
lhs.uploadedResList == rhs.uploadedResList
lhs.uploadedResList == rhs.uploadedResList &&
lhs.showPhotosPicker == rhs.showPhotosPicker &&
lhs.selectedPhotoItems.count == rhs.selectedPhotoItems.count &&
lhs.showDeleteImageAlert == rhs.showDeleteImageAlert &&
lhs.imageToDeleteIndex == rhs.imageToDeleteIndex
}
}
@@ -56,6 +74,12 @@ struct EditFeedFeature {
case uploadImagesResponse(Result<[ResListItem], Error>)
//
case updateImageUploadProgress(Double)
// PhotosPickerAction
case photosPickerDismissed
case addImageButtonTapped
// Action
case showDeleteImageAlert(Int)
case deleteImageAlertDismissed
}
@Dependency(\.apiService) var apiService
@@ -176,6 +200,7 @@ struct EditFeedFeature {
return .none
case .photosPickerItemsChanged(let items):
state.selectedImages = items
state.selectedPhotoItems = items
return .run { send in
await send(.processPhotosPickerItems(items))
}
@@ -203,11 +228,30 @@ struct EditFeedFeature {
if index < state.selectedImages.count {
state.selectedImages.remove(at: index)
}
if index < state.selectedPhotoItems.count {
state.selectedPhotoItems.remove(at: index)
}
return .none
//
case .updateImageUploadProgress(let progress):
state.imageUploadProgress = progress
return .none
// PhotosPickerAction
case .photosPickerDismissed:
state.showPhotosPicker = false
return .none
case .addImageButtonTapped:
state.showPhotosPicker = true
return .none
// Action
case .showDeleteImageAlert(let index):
state.imageToDeleteIndex = index
state.showDeleteImageAlert = true
return .none
case .deleteImageAlertDismissed:
state.showDeleteImageAlert = false
state.imageToDeleteIndex = nil
return .none
}
}
}

View File

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

View File

@@ -4,6 +4,7 @@ import ComposableArchitecture
@Reducer
struct FeedListFeature {
@Dependency(\.apiService) var apiService
@ObservableState
struct State: Equatable {
var isFirstLoad: Bool = true
var feeds: [Feed] = [] // feed
@@ -18,6 +19,15 @@ struct FeedListFeature {
var currentPage: Int = 1
var hasMore: Bool = true
var isLoadingMore: Bool = false
// DetailView
var showDetail: Bool = false
var selectedMoment: MomentsInfo?
//
var likeLoadingDynamicIds: Set<Int> = []
init() {
//
}
}
enum Action: Equatable {
@@ -31,6 +41,12 @@ struct FeedListFeature {
//
case fetchFeeds
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
// DetailViewAction
case showDetail(MomentsInfo)
case detailDismissed
// Action
case likeDynamic(Int, Int, Int, Int) // dynamicId, uid, likedUid, worldId
case likeResponse(TaskResult<LikeDynamicResponse>, dynamicId: Int, loadingId: UUID?)
// Action
}
@@ -129,6 +145,107 @@ struct FeedListFeature {
case .testButtonTapped:
debugInfoSync("[LOG] FeedListFeature testButtonTapped")
return .none
case let .showDetail(moment):
state.selectedMoment = moment
state.showDetail = true
return .none
case .detailDismissed:
state.showDetail = false
state.selectedMoment = nil
return .none
case let .likeDynamic(dynamicId, uid, likedUid, worldId):
// loading
state.likeLoadingDynamicIds.insert(dynamicId)
//
guard let index = state.moments.firstIndex(where: { $0.dynamicId == dynamicId }) else {
//
setAPILoadingErrorSync(UUID(), errorMessage: "找不到对应的动态")
state.likeLoadingDynamicIds.remove(dynamicId)
return .none
}
let currentMoment = state.moments[index]
let status = currentMoment.isLike ? 0 : 1 // 0: , 1:
let request = LikeDynamicRequest(
dynamicId: dynamicId,
uid: uid,
status: status,
likedUid: likedUid,
worldId: worldId
)
return .run { [apiService] send in
let loadingId = await APILoadingManager.shared.startLoading(
shouldShowLoading: request.shouldShowLoading,
shouldShowError: request.shouldShowError
)
do {
let response: LikeDynamicResponse = try await apiService.request(request)
await send(.likeResponse(.success(response), dynamicId: dynamicId, loadingId: loadingId))
} catch {
await send(.likeResponse(.failure(error), dynamicId: dynamicId, loadingId: loadingId))
}
}
case let .likeResponse(.success(response), dynamicId, loadingId):
state.likeLoadingDynamicIds.remove(dynamicId)
if let loadingId = loadingId {
if let data = response.data, let success = data.success, success {
Task { @MainActor in
APILoadingManager.shared.finishLoading(loadingId)
}
if let index = state.moments.firstIndex(where: { $0.dynamicId == dynamicId }) {
let currentMoment = state.moments[index]
let newLikeState = !currentMoment.isLike
let updatedMoment = MomentsInfo(
dynamicId: currentMoment.dynamicId,
uid: currentMoment.uid,
nick: currentMoment.nick,
avatar: currentMoment.avatar,
type: currentMoment.type,
content: currentMoment.content,
likeCount: data.likeCount ?? currentMoment.likeCount,
isLike: newLikeState,
commentCount: currentMoment.commentCount,
publishTime: currentMoment.publishTime,
worldId: currentMoment.worldId,
status: currentMoment.status,
playCount: currentMoment.playCount,
dynamicResList: currentMoment.dynamicResList,
gender: currentMoment.gender,
squareTop: currentMoment.squareTop,
topicTop: currentMoment.topicTop,
newUser: currentMoment.newUser,
defUser: currentMoment.defUser,
scene: currentMoment.scene,
userVipInfoVO: currentMoment.userVipInfoVO,
headwearPic: currentMoment.headwearPic,
headwearEffect: currentMoment.headwearEffect,
headwearType: currentMoment.headwearType,
headwearName: currentMoment.headwearName,
headwearId: currentMoment.headwearId,
experLevelPic: currentMoment.experLevelPic,
charmLevelPic: currentMoment.charmLevelPic,
isCustomWord: currentMoment.isCustomWord,
labelList: currentMoment.labelList
)
state.moments[index] = updatedMoment
}
} else {
let errorMessage = response.message.isEmpty ? "点赞失败,请重试" : response.message
setAPILoadingErrorSync(loadingId, errorMessage: errorMessage)
}
}
return .none
case let .likeResponse(.failure(error), dynamicId, loadingId):
state.likeLoadingDynamicIds.remove(dynamicId)
if let loadingId = loadingId {
setAPILoadingErrorSync(loadingId, errorMessage: error.localizedDescription)
}
return .none
}
}
}
@@ -141,4 +258,4 @@ enum Feed: Equatable, Identifiable {
case .placeholder(let id): return id
}
}
}
}

View File

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

View File

@@ -25,15 +25,15 @@ struct IDLoginFeature {
case failed //
}
#if DEBUG
init() {
self.userID = "2356814"
self.password = "a123456"
self.userID = ""
self.password = ""
}
#endif
}
enum Action: Equatable {
case userIDChanged(String)
case passwordChanged(String)
case togglePasswordVisibility
case loginButtonTapped(userID: String, password: String)
case forgotPasswordTapped
@@ -52,6 +52,12 @@ struct IDLoginFeature {
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .userIDChanged(userID):
state.userID = userID
return .none
case let .passwordChanged(password):
state.password = password
return .none
case .togglePasswordVisibility:
state.isPasswordVisible.toggle()
return .none

View File

@@ -8,6 +8,10 @@ struct InitFeature {
var isLoading = false
var response: InitResponse?
var error: String?
init() {
//
}
}
enum Action: Equatable {

View File

@@ -11,8 +11,6 @@ struct LoginFeature {
var error: String?
var idLoginState = IDLoginFeature.State()
var emailLoginState = EMailLoginFeature.State() //
// HomeFeature
var homeState = HomeFeature.State()
// Account Model Ticket
var accountModel: AccountModel?
@@ -36,13 +34,11 @@ struct LoginFeature {
case failed //
}
#if DEBUG
init() {
//
//
self.account = ""
self.password = ""
}
#endif
}
enum Action {
@@ -54,7 +50,6 @@ struct LoginFeature {
case idLogin(IDLoginFeature.Action)
case emailLogin(EMailLoginFeature.Action) // action
// HomeFeature action
case home(HomeFeature.Action)
// Ticket actions
case requestTicket(accessToken: String)
case ticketResponse(TaskResult<TicketResponse>)
@@ -72,10 +67,6 @@ struct LoginFeature {
Scope(state: \.emailLoginState, action: \.emailLogin) {
EMailLoginFeature()
}
// HomeFeature
Scope(state: \.homeState, action: \.home) {
HomeFeature()
}
Reduce { state, action in
switch action {
@@ -241,8 +232,6 @@ struct LoginFeature {
case .emailLogin:
// EmailLoginfeature
return .none
case .home(_):
return .none
}
}
}

View File

@@ -19,6 +19,10 @@ struct MainFeature {
var appSettingState: AppSettingFeature.State? = nil
//
var isLoggedOut: Bool = false
init() {
//
}
}
//
@@ -74,6 +78,14 @@ struct MainFeature {
return .none
case let .accountModelLoaded(accountModel):
state.accountModel = accountModel
// MeView uid
if state.selectedTab == .other, let uidStr = 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 .me(.settingButtonTapped):
// push

View File

@@ -14,6 +14,10 @@ struct MeDynamicFeature: Reducer {
var hasMore: Bool = true
var error: String?
var isInitialized: Bool = false //
init(uid: Int = 0) {
self.uid = uid
}
}
enum Action: Equatable {

View File

@@ -4,6 +4,7 @@ import ComposableArchitecture
@Reducer
struct MeFeature {
@Dependency(\.apiService) var apiService
@ObservableState
struct State: Equatable {
var isFirstLoad: Bool = true
var userInfo: UserInfo?
@@ -18,6 +19,13 @@ struct MeFeature {
var page: Int = 1
var pageSize: Int = 20
var uid: Int = 0
// DetailView
var showDetail: Bool = false
var selectedMoment: MomentsInfo?
init() {
//
}
}
enum Action: Equatable {
@@ -28,6 +36,9 @@ struct MeFeature {
case momentsResponse(Result<MyMomentsResponse, APIError>)
//
case settingButtonTapped
// DetailViewAction
case showDetail(MomentsInfo)
case detailDismissed
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
@@ -82,6 +93,14 @@ struct MeFeature {
case .settingButtonTapped:
// MainFeature
return .none
case .showDetail(let moment):
state.selectedMoment = moment
state.showDetail = true
return .none
case .detailDismissed:
state.showDetail = false
state.selectedMoment = nil
return .none
}
}

View File

@@ -12,6 +12,10 @@ struct SplashFeature {
//
var navigationDestination: NavigationDestination?
init() {
//
}
}
//

View File

@@ -129,4 +129,81 @@
"appSetting.checkUpdates" = "Check for Updates";
"appSetting.logout" = "Log Out";
"appSetting.aboutUs" = "About Us";
"appSetting.logoutAccount" = "Log out of account";
"appSetting.deactivateAccount" = "Deactivate Account";
"appSetting.logoutAccount" = "Log out of account";
// MARK: - Detail
"detail.title" = "Enjoy your life";
// MARK: - Edit Feed
"edit_feed.uploading_progress" = "Uploading images...%d%%";
// MARK: - Web View
"web_view.load_failed" = "Failed to load page";
"web_view.open_webpage" = "Open Webpage";
// MARK: - Language Settings
"language_settings.select_language" = "Select Language";
"language_settings.current_language" = "Current Language";
"language_settings.language_info" = "Language Info";
"language_settings.test_area" = "Language Switch Test";
"language_settings.test_region" = "Test Area";
"language_settings.token_success" = "✅ Token obtained successfully";
"language_settings.bucket" = "Bucket: %@";
"language_settings.region" = "Region: %@";
"language_settings.app_id" = "App ID: %@";
"language_settings.custom_domain" = "Custom Domain: %@";
"language_settings.accelerate_enabled" = "Enabled";
"language_settings.accelerate_disabled" = "Disabled";
"language_settings.accelerate_status" = "Acceleration: %@";
"language_settings.expiration_date" = "Expiration Date: %@";
"language_settings.remaining_time" = "Remaining Time: %d seconds";
"language_settings.test_cos_token" = "Test Tencent Cloud COS Token";
"language_settings.title" = "Language Settings";
// MARK: - App Settings
"app_settings.error" = "Error";
"app_settings.confirm" = "Confirm";
"app_settings.nickname_limit" = "Nickname must be 15 characters or less";
"app_settings.take_photo" = "Take Photo";
"app_settings.select_from_album" = "Select from Album";
// MARK: - Test
"test.test_page" = "Test Page";
"test.test_description" = "This is a test page\nfor verifying navigation functionality";
"test.test_button" = "Test Button";
"test.back" = "Back";
// MARK: - Image Picker
"image_picker.loading_image" = "Loading image...";
"image_picker.cancel" = "Cancel";
"image_picker.confirm" = "Confirm";
// MARK: - Content View
"content_view.log_level" = "Log Level:";
"content_view.no_log" = "No Log";
"content_view.basic_log" = "Basic Log";
"content_view.detailed_log" = "Detailed Log";
"content_view.api_test_result" = "API Test Result:";
"content_view.status" = "Status: %@";
"content_view.message" = "Message: %@";
"content_view.version" = "Version: %@";
"content_view.unknown" = "Unknown";
"content_view.timestamp" = "Timestamp: %d";
"content_view.config" = "Configuration:";
// MARK: - Screen Adapter
"screen_adapter.method1" = "Method 1: Direct Call";
"screen_adapter.method2" = "Method 2: View Extension";
"screen_adapter.method3" = "Method 3: Ratio Calculation";
// MARK: - Config
"config.api_test" = "API Configuration Test";
"config.loading" = "Loading configuration...";
"config.error" = "Error";
"config.feature_list" = "Feature List";
"config.settings" = "Settings";
"config.last_updated" = "Last Updated: %@";
"config.click_to_load" = "Click the button below to load configuration";
"config.use_new_tca" = "Use new TCA API component";
"config.clear_error" = "Clear Error";

View File

@@ -125,4 +125,81 @@
"appSetting.checkUpdates" = "检查更新";
"appSetting.logout" = "退出登录";
"appSetting.aboutUs" = "关于我们";
"appSetting.deactivateAccount" = "注销帐号";
"appSetting.logoutAccount" = "退出账户";
// MARK: - Detail
"detail.title" = "享受你的生活";
// MARK: - Edit Feed
"edit_feed.uploading_progress" = "正在上传图片...%d%%";
// MARK: - Web View
"web_view.load_failed" = "无法加载页面";
"web_view.open_webpage" = "打开网页";
// MARK: - Language Settings
"language_settings.select_language" = "选择语言";
"language_settings.current_language" = "当前语言";
"language_settings.language_info" = "语言信息";
"language_settings.test_area" = "语言切换测试";
"language_settings.test_region" = "测试区域";
"language_settings.token_success" = "✅ Token 获取成功";
"language_settings.bucket" = "存储桶: %@";
"language_settings.region" = "地域: %@";
"language_settings.app_id" = "应用ID: %@";
"language_settings.custom_domain" = "自定义域名: %@";
"language_settings.accelerate_enabled" = "启用";
"language_settings.accelerate_disabled" = "禁用";
"language_settings.accelerate_status" = "加速: %@";
"language_settings.expiration_date" = "过期时间: %@";
"language_settings.remaining_time" = "剩余时间: %d秒";
"language_settings.test_cos_token" = "测试腾讯云 COS Token";
"language_settings.title" = "语言设置";
// MARK: - App Settings
"app_settings.error" = "错误";
"app_settings.confirm" = "确定";
"app_settings.nickname_limit" = "昵称最长15个字符";
"app_settings.take_photo" = "拍照";
"app_settings.select_from_album" = "从相册选择";
// MARK: - Test
"test.test_page" = "测试页面";
"test.test_description" = "这是一个测试用的页面\n用于验证导航跳转功能";
"test.test_button" = "测试按钮";
"test.back" = "返回";
// MARK: - Image Picker
"image_picker.loading_image" = "加载图片中...";
"image_picker.cancel" = "取消";
"image_picker.confirm" = "确认";
// MARK: - Content View
"content_view.log_level" = "日志级别:";
"content_view.no_log" = "无日志";
"content_view.basic_log" = "基础日志";
"content_view.detailed_log" = "详细日志";
"content_view.api_test_result" = "API 测试结果:";
"content_view.status" = "状态: %@";
"content_view.message" = "消息: %@";
"content_view.version" = "版本: %@";
"content_view.unknown" = "未知";
"content_view.timestamp" = "时间戳: %d";
"content_view.config" = "配置:";
// MARK: - Screen Adapter
"screen_adapter.method1" = "方法1: 直接调用";
"screen_adapter.method2" = "方法2: View Extension";
"screen_adapter.method3" = "方法3: 比例计算";
// MARK: - Config
"config.api_test" = "API 配置测试";
"config.loading" = "正在加载配置...";
"config.error" = "错误";
"config.feature_list" = "功能列表";
"config.settings" = "设置";
"config.last_updated" = "最后更新: %@";
"config.click_to_load" = "点击下方按钮加载配置";
"config.use_new_tca" = "使用新的 TCA API 组件";
"config.clear_error" = "清除错误";

View File

@@ -137,91 +137,91 @@ private struct SimpleErrorView: View {
// MARK: - Preview
#if DEBUG
struct APILoadingEffectView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
//
Rectangle()
.fill(Color.blue.opacity(0.3))
.ignoresSafeArea()
VStack(spacing: 20) {
Text("背景内")
.font(.title)
Button("测试按") {
debugInfoSync("按钮被点击了")
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
// Loading Effect View
APILoadingEffectView()
}
.previewDisplayName("API Loading Effect")
.onAppear {
//
Task {
let manager = APILoadingManager.shared
// loading
let id1 = manager.startLoading()
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
Task {
manager.setError(id1, errorMessage: "网络连接失败,请检查网络设")
}
}
}
}
}
}
// MARK: - Preview Helpers
///
private struct PreviewStateModifier: ViewModifier {
let showLoading: Bool
let showError: Bool
let errorMessage: String
func body(content: Content) -> some View {
content
.onAppear {
Task {
let manager = APILoadingManager.shared
if showLoading {
let _ = manager.startLoading()
}
if showError {
let id = manager.startLoading()
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1
manager.setError(id, errorMessage: errorMessage)
}
}
}
}
}
extension View {
///
func previewLoadingState(
showLoading: Bool = false,
showError: Bool = false,
errorMessage: String = "示例错误信"
) -> some View {
self.modifier(PreviewStateModifier(
showLoading: showLoading,
showError: showError,
errorMessage: errorMessage
))
}
}
#endif
//#if DEBUG
//struct APILoadingEffectView_Previews: PreviewProvider {
// static var previews: some View {
// ZStack {
// //
// Rectangle()
// .fill(Color.blue.opacity(0.3))
// .ignoresSafeArea()
//
// VStack(spacing: 20) {
// Text("")
// .font(.title)
//
// Button("") {
// debugInfoSync("")
// }
// .padding()
// .background(Color.blue)
// .foregroundColor(.white)
// .cornerRadius(8)
// }
//
// // Loading Effect View
// APILoadingEffectView()
// }
// .previewDisplayName("API Loading Effect")
// .onAppear {
// //
// Task {
// let manager = APILoadingManager.shared
//
// // loading
// let id1 = manager.startLoading()
//
// // 2
// DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
// Task {
// manager.setError(id1, errorMessage: "")
// }
// }
// }
// }
// }
//}
//
//// MARK: - Preview Helpers
//
/////
//private struct PreviewStateModifier: ViewModifier {
// let showLoading: Bool
// let showError: Bool
// let errorMessage: String
//
// func body(content: Content) -> some View {
// content
// .onAppear {
// Task {
// let manager = APILoadingManager.shared
//
// if showLoading {
// let _ = manager.startLoading()
// }
//
// if showError {
// let id = manager.startLoading()
// try? await Task.sleep(nanoseconds: 1_000_000_000) // 1
// manager.setError(id, errorMessage: errorMessage)
// }
// }
// }
// }
//}
//
//extension View {
// ///
// func previewLoadingState(
// showLoading: Bool = false,
// showError: Bool = false,
// errorMessage: String = ""
// ) -> some View {
// self.modifier(PreviewStateModifier(
// showLoading: showLoading,
// showError: showError,
// errorMessage: errorMessage
// ))
// }
//}
//#endif

View File

@@ -134,4 +134,18 @@ extension APILoadingManager {
return .failure(error)
}
}
}
// MARK: - Global Convenience Methods
/// 便fire-and-forget
/// - Parameters:
/// - id: ID
/// - errorMessage:
func setAPILoadingErrorSync(_ id: UUID, errorMessage: String) {
Task {
await MainActor.run {
APILoadingManager.shared.setError(id, errorMessage: errorMessage)
}
}
}

View File

@@ -114,26 +114,51 @@ class LocalizationManager: ObservableObject {
}
// MARK: - SwiftUI Extensions
// extension View {
// ///
// /// - Parameter key: key
// /// - Returns:
// @MainActor
// func localized(_ key: String) -> some View {
// self.modifier(LocalizedTextModifier(key: key))
// }
// }
extension View {
///
/// - Parameter key: key
/// - Returns:
@MainActor
func localized(_ key: String) -> some View {
self.modifier(LocalizedTextModifier(key: key))
}
}
// MARK: - 便
// extension String {
// ///
// @MainActor
// var localized: String {
// return LocalizationManager.shared.localizedString(self)
// }
// ///
// @MainActor
// func localized(arguments: CVarArg...) -> String {
// return LocalizationManager.shared.localizedString(self, arguments: arguments)
// }
// }
extension String {
///
@MainActor
var localized: String {
return LocalizationManager.shared.localizedString(self)
}
///
@MainActor
func localized(arguments: CVarArg...) -> String {
return LocalizationManager.shared.localizedString(self, arguments: arguments)
}
}
// MARK: -
///
/// 使 LocalizationManager
/// - Parameters:
/// - key: key
/// - comment: NSLocalizedString
/// - Returns:
@MainActor
func LocalizedString(_ key: String, comment: String = "") -> String {
return LocalizationManager.shared.localizedString(key)
}
// MARK: - LocalizedTextModifier
///
struct LocalizedTextModifier: ViewModifier {
let key: String
func body(content: Content) -> some View {
content
.onAppear {
//
}
}
}

View File

@@ -6,25 +6,25 @@ struct ScreenAdapterExample: View {
var body: some View {
GeometryReader { geometry in
VStack(spacing: 20) {
Text(LocalizedString("screen_adapter.method1", comment: ""))
.font(.headline)
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
// 1: 使 ScreenAdapter
Text("方法1: 直接调用")
.font(.system(size: ScreenAdapter.fontSize(16, for: geometry.size.width)))
.padding(.leading, ScreenAdapter.width(20, for: geometry.size.width))
.padding(.top, ScreenAdapter.height(50, for: geometry.size.height))
Text(LocalizedString("screen_adapter.method2", comment: ""))
.font(.headline)
.padding()
.background(Color.green.opacity(0.1))
.cornerRadius(8)
// 2: 使 View Extension ()
Text("方法2: View Extension")
.adaptedFont(16)
.adaptedHeight(50)
// 3: 使
Text("方法3: 比例计算")
.font(.system(size: 16 * ScreenAdapter.widthRatio(for: geometry.size.width)))
.padding(.top, 50 * ScreenAdapter.heightRatio(for: geometry.size.height))
Spacer()
Text(LocalizedString("screen_adapter.method3", comment: ""))
.font(.headline)
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
}
.padding()
}
}
}

View File

@@ -31,444 +31,327 @@ struct AppSettingView: View {
@State private var errorMessage: String? = nil
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
WithPerceptionTracking {
mainView()
}
}
@ViewBuilder
private func mainView() -> some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
mainContent(viewStore: viewStore)
}
.confirmationDialog(
"请选择图片来源",
isPresented: $showActionSheet,
titleVisibility: .visible
) {
Button("拍照") { showCamera = true }
Button("从相册选择") { showPhotoPicker = true }
Button("取消", role: .cancel) {}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotoItems,
maxSelectionCount: 1,
matching: .images
)
.sheet(isPresented: $showCamera) {
CameraPicker { image in
print("[LOG] CameraPicker回调image: \(image != nil)")
if let image = image {
print("[LOG] CameraPicker获得图片直接上传头像")
if let data = image.jpegData(compressionQuality: 0.8) {
viewStore.send(AppSettingFeature.Action.avatarSelected(data))
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
VStack(spacing: 0) {
//
HStack {
Button(action: {
store.send(.dismissTapped)
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
} else {
errorMessage = "拍照失败,请重试"
//
showPreview = false
showCamera = false
showPhotoPicker = false
showActionSheet = false
print("[LOG] CameraPicker无图片弹出错误提示")
Spacer()
Text(LocalizedString("app_settings.title", comment: "设置"))
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
Spacer()
//
Color.clear
.frame(width: 44, height: 44)
}
.padding(.horizontal, 16)
.padding(.top, 8)
//
ScrollView {
VStack(spacing: 20) {
//
avatarSection()
//
personalInfoSection()
//
otherSettingsSection()
// 退
logoutSection()
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 40)
}
showCamera = false
}
}
.fullScreenCover(isPresented: $showPreview) {
ImagePreviewView(
images: $selectedImages,
currentIndex: .constant(0),
onConfirm: {
print("[LOG] 预览确认,准备上传头像")
if let image = selectedImages.first, let data = image.jpegData(compressionQuality: 0.8) {
viewStore.send(AppSettingFeature.Action.avatarSelected(data))
}
showPreview = false
},
onCancel: {
print("[LOG] 预览取消")
showPreview = false
}
.navigationBarHidden(true)
//
.sheet(isPresented: $showImagePickerSheet) {
ImagePickerWithPreviewView(
store: pickerStore,
onUpload: { images in
if let firstImage = images.first,
let imageData = firstImage.jpegData(compressionQuality: 0.8) {
store.send(AppSettingFeature.Action.avatarSelected(imageData))
}
showImagePickerSheet = false
},
onCancel: {
showImagePickerSheet = false
}
)
}
//
.sheet(isPresented: $showCamera) {
ImagePickerWithPreviewView(
store: pickerStore,
onUpload: { images in
if let firstImage = images.first,
let imageData = firstImage.jpegData(compressionQuality: 0.8) {
store.send(AppSettingFeature.Action.avatarSelected(imageData))
}
showCamera = false
},
onCancel: {
showCamera = false
}
)
}
//
.alert(LocalizedString("app_settings.edit_nickname", comment: "编辑昵称"), isPresented: $showNicknameAlert) {
TextField(LocalizedString("app_settings.nickname_placeholder", comment: "请输入昵称"), text: $nicknameInput)
Button(LocalizedString("app_settings.cancel", comment: "取消")) {
showNicknameAlert = false
nicknameInput = ""
}
Button(LocalizedString("app_settings.confirm", comment: "确认")) {
let trimmed = nicknameInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
store.send(.nicknameEditConfirmed(trimmed))
}
showNicknameAlert = false
nicknameInput = ""
}
} message: {
Text(LocalizedString("app_settings.nickname_tip", comment: "请输入新的昵称"))
}
}
}
// MARK: -
@ViewBuilder
private func avatarSection() -> some View {
WithPerceptionTracking {
VStack(spacing: 16) {
//
Button(action: {
showImagePickerSheet = true
}) {
AsyncImage(url: URL(string: store.userInfo?.avatar ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Image(systemName: "person.circle.fill")
.resizable()
.aspectRatio(contentMode: .fill)
.foregroundColor(.gray)
}
.frame(width: 80, height: 80)
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 2)
)
}
.onChange(of: selectedPhotoItems) { items in
print("[LOG] PhotosPicker选中items: \(items.count)")
guard !items.isEmpty else { return }
isLoading = true
selectedImages = []
let group = DispatchGroup()
var tempImages: [UIImage] = []
for item in items {
group.enter()
item.loadTransferable(type: Data.self) { result in
defer { group.leave() }
if let data = try? result.get(), let uiImage = UIImage(data: data) {
DispatchQueue.main.async {
tempImages.append(uiImage)
print("[LOG] 成功加载图片当前tempImages数量: \(tempImages.count)")
}
} else {
print("[LOG] 图片加载失败")
}
}
}
DispatchQueue.global().async {
group.wait()
DispatchQueue.main.async {
isLoading = false
print("[LOG] 所有图片加载完成tempImages数量: \(tempImages.count)")
if tempImages.isEmpty {
errorMessage = "图片加载失败,请重试"
//
showPreview = false
showCamera = false
showPhotoPicker = false
showActionSheet = false
print("[LOG] PhotosPicker图片加载失败弹出错误提示")
} else {
// selectedImages
selectedImages = tempImages
print("[LOG] selectedImages已设置数量: \(selectedImages.count)")
// 线showPreview
DispatchQueue.main.async {
showPreview = true
print("[LOG] showPreview已设置为true")
}
}
}
}
}
.alert(isPresented: Binding<Bool>(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
print("[LOG] 错误弹窗弹出: \(errorMessage ?? "")")
return Alert(title: Text("错误"), message: Text(errorMessage ?? ""), dismissButton: .default(Text("确定"), action: {
// actionset
showPreview = false
showCamera = false
showPhotoPicker = false
showActionSheet = false
}))
}
.navigationBarHidden(true)
.alert("修改昵称", isPresented: $showNicknameAlert) {
nicknameAlertContent(viewStore: viewStore)
} message: {
Text("昵称最长15个字符")
}
.sheet(isPresented: userAgreementBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/user-agreement")!)
}
.sheet(isPresented: privacyPolicyBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/privacy-policy")!)
}
Text(LocalizedString("app_settings.tap_to_change_avatar", comment: "点击更换头像"))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.vertical, 20)
.frame(maxWidth: .infinity)
.background(Color.white.opacity(0.1))
.cornerRadius(12)
}
}
// MARK: -
private func mainContent(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
ZStack {
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
// MARK: -
@ViewBuilder
private func personalInfoSection() -> some View {
WithPerceptionTracking {
VStack(spacing: 0) {
topBar
ScrollView {
WithPerceptionTracking {
VStack(spacing: 32) {
//
avatarSection(viewStore: viewStore)
//
nicknameSection(viewStore: viewStore)
//
settingsSection(viewStore: viewStore)
// 退
logoutButton(viewStore: viewStore)
}
//
SettingRow(
icon: "person",
title: LocalizedString("app_settings.nickname", comment: "昵称"),
subtitle: store.userInfo?.nick ?? LocalizedString("app_settings.not_set", comment: "未设置"),
action: {
nicknameInput = store.userInfo?.nick ?? ""
showNicknameAlert = true
}
}
)
Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 50)
// ID
SettingRow(
icon: "number",
title: LocalizedString("app_settings.user_id", comment: "用户ID"),
subtitle: "\(store.userInfo?.uid ?? 0)",
action: nil
)
}
.background(Color.white.opacity(0.1))
.cornerRadius(12)
}
}
// MARK: -
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
ZStack(alignment: .bottomTrailing) {
avatarImageView(viewStore: viewStore)
.onTapGesture {
showActionSheet = true
}
cameraButton(viewStore: viewStore)
}
.padding(.top, 24)
}
// MARK: -
// MARK: -
@ViewBuilder
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
if viewStore.isUploadingAvatar || 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 func cameraButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
Button(action: {
showActionSheet = true
}) {
ZStack {
Circle().fill(Color.purple).frame(width: 36, height: 36)
Image(systemName: "camera.fill")
.foregroundColor(.white)
private func otherSettingsSection() -> some View {
WithPerceptionTracking {
VStack(spacing: 0) {
SettingRow(
icon: "hand.raised",
title: LocalizedString("app_settings.personal_info_permissions", comment: "个人信息权限"),
subtitle: LocalizedString("app_settings.manage_permissions", comment: "管理权限"),
action: { store.send(.personalInfoPermissionsTapped) }
)
Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 50)
SettingRow(
icon: "questionmark.circle",
title: LocalizedString("app_settings.help", comment: "帮助"),
subtitle: LocalizedString("app_settings.get_help", comment: "获取帮助"),
action: { store.send(.helpTapped) }
)
Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 50)
SettingRow(
icon: "trash",
title: LocalizedString("app_settings.clear_cache", comment: "清除缓存"),
subtitle: LocalizedString("app_settings.free_up_space", comment: "释放空间"),
action: { store.send(.clearCacheTapped) }
)
Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 50)
SettingRow(
icon: "arrow.clockwise",
title: LocalizedString("app_settings.check_updates", comment: "检查更新"),
subtitle: LocalizedString("app_settings.latest_version", comment: "最新版本"),
action: { store.send(.checkUpdatesTapped) }
)
Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 50)
SettingRow(
icon: "person.crop.circle.badge.minus",
title: LocalizedString("app_settings.deactivate_account", comment: "注销账号"),
subtitle: LocalizedString("app_settings.permanent_deletion", comment: "永久删除"),
action: { store.send(.deactivateAccountTapped) }
)
Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 50)
SettingRow(
icon: "info.circle",
title: LocalizedString("app_settings.about_us", comment: "关于我们"),
subtitle: LocalizedString("app_settings.app_info", comment: "应用信息"),
action: { store.send(.aboutUsTapped) }
)
}
}
.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 {
nicknameInput = viewStore.nickname
showNicknameAlert = true
}
Divider().background(Color.gray.opacity(0.3))
.padding(.horizontal, 32)
.background(Color.white.opacity(0.1))
.cornerRadius(12)
}
}
// 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
)
}
// MARK: - Alert
// MARK: - 退
@ViewBuilder
private func nicknameAlertContent(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
TextField("请输入昵称", text: $nicknameInput)
.onChange(of: nicknameInput) { newValue in
if newValue.count > 15 {
nicknameInput = String(newValue.prefix(15))
}
}
Button("确定") {
let trimmed = nicknameInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && trimmed != viewStore.nickname {
viewStore.send(.nicknameEditConfirmed(trimmed))
private func logoutSection() -> some View {
WithPerceptionTracking {
Button(action: {
store.send(.logoutTapped)
}) {
Text(LocalizedString("app_settings.logout", comment: "退出登录"))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.red.opacity(0.8))
.cornerRadius(12)
}
}
Button("取消", role: .cancel) {}
}
}
// MARK: -
private var topBar: some View {
HStack {
WithViewStore(store, observe: { $0 }) { viewStore in
Button(action: {
viewStore.send(.dismissTapped)
}) {
Image(systemName: "chevron.left")
// MARK: -
struct SettingRow: View {
let icon: String
let title: String
let subtitle: String
let action: (() -> Void)?
var body: some View {
Button(action: {
action?()
}) {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(.white)
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 16))
.foregroundColor(.white)
.font(.system(size: 20, weight: .medium))
Text(subtitle)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
Spacer()
if action != nil {
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.5))
}
}
Spacer()
Text(NSLocalizedString("appSetting.title", comment: "Settings"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
Spacer()
//
Color.clear
.frame(width: 20, height: 20)
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 16)
}
// MARK: -
private func loadAndProcessImage(item: PhotosPickerItem, completion: @escaping @Sendable (Data?) -> Void) {
item.loadTransferable(type: Data.self) { result in
guard let data = try? result.get(), let uiImage = UIImage(data: data) else {
completion(nil)
return
}
let square = cropToSquare(image: uiImage)
let resized = resizeImage(image: square, targetSize: CGSize(width: 180, height: 180))
let jpegData = resized.jpegData(compressionQuality: 0.8)
completion(jpegData)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
// MARK: -
private func cropToSquare(image: UIImage) -> UIImage {
let size = min(image.size.width, image.size.height)
let x = (image.size.width - size) / 2
let y = (image.size.height - size) / 2
let cropRect = CGRect(x: x, y: y, width: size, height: size)
guard let cgImage = image.cgImage?.cropping(to: cropRect) else { return image }
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
private func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
.disabled(action == nil)
}
}

View File

@@ -9,6 +9,7 @@ public struct ImagePickerWithPreviewView: View {
@State private var loadedImages: [UIImage] = []
@State private var isLoadingImages: Bool = false
@State private var loadingId: UUID?
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void, onCancel: @escaping () -> Void) {
self.store = store
@@ -20,29 +21,21 @@ public struct ImagePickerWithPreviewView: View {
WithViewStore(store, observe: { $0 }) { viewStore in
ZStack {
Color.clear
LoadingView(isLoading: viewStore.inner.isLoading || isLoadingImages)
}
.background(.clear)
.modifier(ActionSheetModifier(viewStore: viewStore, onCancel: onCancel))
.modifier(CameraSheetModifier(viewStore: viewStore))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages, loadingId: $loadingId))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload, loadingId: $loadingId))
.modifier(ErrorToastModifier(viewStore: viewStore))
}
}
}
private struct LoadingView: View {
let isLoading: Bool
var body: some View {
if isLoading {
Color.black.opacity(0.4).ignoresSafeArea()
ProgressView("上传中...")
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(16)
.onChange(of: viewStore.inner.isLoading) { isLoading in
if isLoading && loadingId == nil {
loadingId = APILoadingManager.shared.startLoading()
} else if !isLoading, let id = loadingId {
APILoadingManager.shared.finishLoading(id)
loadingId = nil
}
}
}
}
}
@@ -59,8 +52,8 @@ private struct ActionSheetModifier: ViewModifier {
),
titleVisibility: .visible
) {
Button("拍照") { viewStore.send(.inner(.selectSource(.camera))) }
Button("从相册选择") { viewStore.send(.inner(.selectSource(.photoLibrary))) }
Button(LocalizedString("app_settings.take_photo", comment: "")) { viewStore.send(.inner(.selectSource(.camera))) }
Button(LocalizedString("app_settings.select_from_album", comment: "")) { viewStore.send(.inner(.selectSource(.photoLibrary))) }
Button("取消", role: .cancel) { onCancel() }
}
}
@@ -84,6 +77,7 @@ private struct PhotosPickerModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
@Binding var loadedImages: [UIImage]
@Binding var isLoadingImages: Bool
@Binding var loadingId: UUID?
func body(content: Content) -> some View {
content
.photosPicker(
@@ -136,6 +130,7 @@ private struct PreviewCoverModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
let loadedImages: [UIImage]
let onUpload: ([UIImage]) -> Void
@Binding var loadingId: UUID?
func body(content: Content) -> some View {
content.fullScreenCover(isPresented: .init(
get: { viewStore.inner.showPreview },

View File

@@ -35,7 +35,7 @@ public struct ImagePreviewView: View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.5)
Text("加载图片中...")
Text(LocalizedString("image_picker.loading_image", comment: ""))
.foregroundColor(.white)
.padding(.top, 16)
}
@@ -43,7 +43,7 @@ public struct ImagePreviewView: View {
Spacer()
HStack(spacing: 24) {
Button(action: onCancel) {
Text("取消")
Text(LocalizedString("image_picker.cancel", comment: ""))
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 12)
@@ -51,7 +51,7 @@ public struct ImagePreviewView: View {
.cornerRadius(20)
}
Button(action: onConfirm) {
Text("确认")
Text(LocalizedString("image_picker.confirm", comment: ""))
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 12)

View File

@@ -9,24 +9,39 @@ struct OptimizedDynamicCardView: View {
let currentIndex: Int
//
let onImageTap: (_ images: [String], _ index: Int) -> Void
//
let onLikeTap: (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void
//
let onCardTap: (() -> Void)?
//
let isDetailMode: Bool
// loading
let isLikeLoading: Bool
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void) {
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void, onLikeTap: @escaping (_ dynamicId: Int, _ uid: Int, _ likedUid: Int, _ worldId: Int) -> Void, onCardTap: (() -> Void)? = nil, isDetailMode: Bool = false, isLikeLoading: Bool = false) {
self.moment = moment
self.allMoments = allMoments
self.currentIndex = currentIndex
self.onImageTap = onImageTap
self.onLikeTap = onLikeTap
self.onCardTap = onCardTap
self.isDetailMode = isDetailMode
self.isLikeLoading = isLikeLoading
}
public var body: some View {
ZStack {
//
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
// -
if !isDetailMode {
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
}
//
VStack(alignment: .leading, spacing: 12) {
//
@@ -47,14 +62,17 @@ struct OptimizedDynamicCardView: View {
}
.frame(width: 40, height: 40)
.clipShape(Circle())
.allowsHitTesting(false) //
VStack(alignment: .leading, spacing: 2) {
Text(moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.allowsHitTesting(false) //
Text("ID: \(moment.uid)")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
.allowsHitTesting(false) //
}
Spacer()
// VIP
@@ -65,6 +83,7 @@ struct OptimizedDynamicCardView: View {
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
.allowsHitTesting(false) //
}
//
@@ -74,6 +93,7 @@ struct OptimizedDynamicCardView: View {
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
.allowsHitTesting(false) //
}
//
@@ -83,25 +103,46 @@ struct OptimizedDynamicCardView: View {
onImageTap(urls, tappedIndex)
}
.padding(.bottom, images.count == 2 ? 46 : 0) //
.allowsHitTesting(true) //
}
//
HStack(spacing: 20) {
// Like
Button(action: {}) {
// Like
Button(action: {
if !isLikeLoading {
onLikeTap(moment.dynamicId, moment.uid, moment.uid, moment.worldId)
}
}) {
HStack(spacing: 4) {
Image(systemName: moment.isLike ? "heart.fill" : "heart")
.font(.system(size: 16))
if isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: moment.isLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: moment.isLike ? "heart.fill" : "heart")
.font(.system(size: 16))
}
Text("\(moment.likeCount)")
.font(.system(size: 14))
}
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
}
.disabled(isLikeLoading)
.padding(.leading, 40 + 8) // +
.allowsHitTesting(true) // Like
Spacer()
}
.padding(.top, 8)
}
.padding(16)
// -
.contentShape(Rectangle())
.onTapGesture {
if !isDetailMode, let onCardTap = onCardTap {
onCardTap()
}
}
}
.onAppear {
preloadNearbyImages()

View File

@@ -38,20 +38,20 @@ struct UserAgreementView: View {
// MARK: - Private Methods
private func createAttributedText() -> AttributedString {
var attributedString = AttributedString(NSLocalizedString("login.agreement_policy", comment: ""))
var attributedString = AttributedString(LocalizedString("login.agreement_policy", comment: ""))
//
attributedString.foregroundColor = Color(hex: 0x666666)
// ""
if let userServiceRange = attributedString.range(of: NSLocalizedString("login.agreement", comment: "")) {
if let userServiceRange = attributedString.range(of: LocalizedString("login.agreement", comment: "")) {
attributedString[userServiceRange].foregroundColor = Color(hex: 0x8A4FFF)
attributedString[userServiceRange].underlineStyle = .single
attributedString[userServiceRange].link = URL(string: "user-service-agreement")
}
// ""
if let privacyPolicyRange = attributedString.range(of: NSLocalizedString("login.policy", comment: "")) {
if let privacyPolicyRange = attributedString.range(of: LocalizedString("login.policy", comment: "")) {
attributedString[privacyPolicyRange].foregroundColor = Color(hex: 0x8A4FFF)
attributedString[privacyPolicyRange].underlineStyle = .single
attributedString[privacyPolicyRange].link = URL(string: "privacy-policy")

View File

@@ -34,7 +34,7 @@ extension View {
if let url = url {
WebView(url: url)
} else {
Text("无法加载页面")
Text(LocalizedString("web_view.load_failed", comment: ""))
.foregroundColor(.red)
.padding()
}
@@ -44,7 +44,7 @@ extension View {
#Preview {
VStack {
Button("打开网页") {
Button(LocalizedString("web_view.open_webpage", comment: "")) {
//
}
}

View File

@@ -7,161 +7,167 @@ struct CreateFeedView: View {
@State private var keyboardHeight: CGFloat = 0
var body: some View {
NavigationStack {
GeometryReader { geometry in
VStack(spacing: 0) {
//
Color(hex: 0x0C0527)
.ignoresSafeArea()
// ScrollView
VStack(spacing: 20) {
//
VStack(alignment: .leading, spacing: 12) {
//
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.1))
WithPerceptionTracking {
NavigationStack {
GeometryReader { geometry in
VStack(spacing: 0) {
//
Color(hex: 0x0C0527)
.ignoresSafeArea()
// ScrollView
VStack(spacing: 20) {
//
VStack(alignment: .leading, spacing: 12) {
//
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.1))
.frame(height: 200) // 200
if store.content.isEmpty {
Text(NSLocalizedString("createFeed.enterContent", comment: "Enter Content"))
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
TextEditor(text: .init(
get: { store.content },
set: { store.send(.contentChanged($0)) }
))
.foregroundColor(.white)
.background(Color.clear)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.scrollContentBackground(.hidden)
.frame(height: 200) // 200
if store.content.isEmpty {
Text(NSLocalizedString("createFeed.enterContent", comment: "Enter Content"))
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
TextEditor(text: .init(
get: { store.content },
set: { store.send(.contentChanged($0)) }
))
.foregroundColor(.white)
.background(Color.clear)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.scrollContentBackground(.hidden)
.frame(height: 200) // 200
//
HStack {
Spacer()
Text("\(store.characterCount)/500")
.font(.system(size: 12))
.foregroundColor(
store.characterCount > 500 ? .red : .white.opacity(0.6)
)
}
}
.padding(.horizontal, 20)
.padding(.top, 20)
//
HStack {
Spacer()
Text("\(store.characterCount)/500")
.font(.system(size: 12))
.foregroundColor(
store.characterCount > 500 ? .red : .white.opacity(0.6)
//
VStack(alignment: .leading, spacing: 12) {
if !store.processedImages.isEmpty || store.canAddMoreImages {
ModernImageSelectionGrid(
images: store.processedImages,
selectedItems: store.selectedImages,
canAddMore: store.canAddMoreImages,
onItemsChanged: { items in
store.send(.photosPickerItemsChanged(items))
},
onRemoveImage: { index in
store.send(.removeImage(index))
}
)
}
}
}
.padding(.horizontal, 20)
.padding(.top, 20)
//
VStack(alignment: .leading, spacing: 12) {
if !store.processedImages.isEmpty || store.canAddMoreImages {
ModernImageSelectionGrid(
images: store.processedImages,
selectedItems: store.selectedImages,
canAddMore: store.canAddMoreImages,
onItemsChanged: { items in
store.send(.photosPickerItemsChanged(items))
},
onRemoveImage: { index in
store.send(.removeImage(index))
}
)
}
}
.padding(.horizontal, 20)
//
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text(NSLocalizedString("createFeed.processingImages", comment: "Processing images..."))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 10)
}
//
if let error = store.errorMessage {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.horizontal, 20)
.multilineTextAlignment(.center)
}
//
Color.clear.frame(height: max(keyboardHeight, geometry.safeAreaInsets.bottom) + 100)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.ignoresSafeArea(.keyboard, edges: .bottom)
// -
VStack {
Button(action: {
store.send(.publishButtonTapped)
}) {
HStack {
if store.isLoading {
.padding(.horizontal, 20)
//
if store.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
Text(NSLocalizedString("createFeed.publishing", comment: "Publishing..."))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
} else {
Text(NSLocalizedString("createFeed.publish", comment: "Publish"))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text(NSLocalizedString("createFeed.processingImages", comment: "Processing images..."))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 10)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
Color(hex: 0x0C0527)
)
.cornerRadius(25)
.disabled(store.isLoading || !store.canPublish)
.opacity(store.isLoading || !store.canPublish ? 0.6 : 1.0)
//
if let error = store.errorMessage {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.horizontal, 20)
.multilineTextAlignment(.center)
}
//
Color.clear.frame(height: max(keyboardHeight, geometry.safeAreaInsets.bottom) + 100)
}
.padding(.horizontal, 20)
.padding(.bottom, max(keyboardHeight, geometry.safeAreaInsets.bottom) + 20)
}
.background(
Color(hex: 0x0C0527)
)
}
}
.navigationTitle(NSLocalizedString("createFeed.title", comment: "Image & Text Publish"))
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
store.send(.dismissView)
}) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.ignoresSafeArea(.keyboard, edges: .bottom)
// -
VStack {
Button(action: {
store.send(.publishButtonTapped)
}) {
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
Text(NSLocalizedString("createFeed.publishing", comment: "Publishing..."))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
} else {
Text(NSLocalizedString("createFeed.publish", comment: "Publish"))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
Color(hex: 0x0C0527)
)
.cornerRadius(25)
.disabled(store.isLoading || !store.canPublish)
.opacity(store.isLoading || !store.canPublish ? 0.6 : 1.0)
}
.padding(.horizontal, 20)
.padding(.bottom, max(keyboardHeight, geometry.safeAreaInsets.bottom) + 20)
}
.background(
Color(hex: 0x0C0527)
)
}
}
//
.navigationTitle(NSLocalizedString("createFeed.title", comment: "Image & Text Publish"))
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
store.send(.dismissView)
}) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
}
}
//
}
}
}
.preferredColorScheme(.dark)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height
.preferredColorScheme(.dark)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
keyboardHeight = 0
}
.onDisappear {
//
keyboardHeight = 0
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
keyboardHeight = 0
}
}

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

@@ -0,0 +1,228 @@
import SwiftUI
import ComposableArchitecture
struct DetailView: View {
@State var store: StoreOf<DetailFeature>
let onLikeSuccess: ((Int, Bool) -> Void)?
init(store: StoreOf<DetailFeature>, onLikeSuccess: ((Int, Bool) -> Void)? = nil) {
self.store = store
self.onLikeSuccess = onLikeSuccess
}
var body: some View {
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea()
VStack(spacing: 0) {
//
WithPerceptionTracking {
CustomNavigationBar(
title: NSLocalizedString("detail.title", comment: "Detail page title"),
showDeleteButton: isCurrentUserDynamic,
isDeleteLoading: store.isDeleteLoading,
onBack: {
// onDismiss?() 使 dismiss()
},
onDelete: {
store.send(.deleteDynamic)
}
)
}
.padding(.top, 24)
//
ScrollView {
VStack(spacing: 0) {
// 使OptimizedDynamicCardView
WithPerceptionTracking {
OptimizedDynamicCardView(
moment: store.moment,
allMoments: [store.moment], //
currentIndex: 0,
onImageTap: { images, index in
store.send(.showImagePreview(images, index))
},
onLikeTap: { dynamicId, uid, likedUid, worldId in
store.send(.likeDynamic(dynamicId, uid, likedUid, worldId))
},
onCardTap: nil, //
isDetailMode: true, //
isLikeLoading: store.isLikeLoading
)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 116)
}
}
}
}
}
.navigationBarHidden(true)
.onAppear {
debugInfoSync("🔍 DetailView: onAppear - moment.uid: \(store.moment.uid)")
store.send(.onAppear)
}
.onChange(of: store.shouldDismiss) { shouldDismiss in
if shouldDismiss {
debugInfoSync("🔍 DetailView: shouldDismiss = true, 调用onDismiss")
// onDismiss?() 使 dismiss
}
}
.fullScreenCover(isPresented: Binding(
get: { store.showImagePreview },
set: { _ in store.send(.hideImagePreview) }
)) {
WithPerceptionTracking {
ImagePreviewPager(
images: store.selectedImages,
currentIndex: Binding(
get: { store.selectedImageIndex },
set: { newIndex in
store.send(.showImagePreview(store.selectedImages, newIndex))
}
),
onClose: {
store.send(.imagePreviewDismissed)
}
)
}
}
}
//
private var isCurrentUserDynamic: Bool {
// 使storeID
guard let currentUserId = store.currentUserId,
let currentUserIdInt = Int(currentUserId) else {
debugInfoSync("🔍 DetailView: 无法获取当前用户ID - currentUserId: \(store.currentUserId ?? "nil")")
return false
}
let isCurrentUser = store.moment.uid == currentUserIdInt
debugInfoSync("🔍 DetailView: 动态用户判断 - moment.uid: \(store.moment.uid), currentUserId: \(currentUserIdInt), isCurrentUser: \(isCurrentUser)")
return isCurrentUser
}
}
// MARK: - CustomNavigationBar
struct CustomNavigationBar: View {
let title: String
let showDeleteButton: Bool
let isDeleteLoading: Bool
let onBack: () -> Void
let onDelete: () -> Void
init(title: String, showDeleteButton: Bool, isDeleteLoading: Bool, onBack: @escaping () -> Void, onDelete: @escaping () -> Void) {
self.title = title
self.showDeleteButton = showDeleteButton
self.isDeleteLoading = isDeleteLoading
self.onBack = onBack
self.onDelete = onDelete
}
@SwiftUI.Environment(\.dismiss) private var dismiss: SwiftUI.DismissAction
var body: some View {
HStack {
//
Button(action: {
onBack()
dismiss() // 使 dismiss
}) {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
Spacer()
//
Text(title)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
Spacer()
//
WithPerceptionTracking {
if showDeleteButton {
Button(action: onDelete) {
if isDeleteLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
.frame(width: 44, height: 44)
.background(Color.red.opacity(0.8))
.clipShape(Circle())
} else {
Image(systemName: "trash")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Color.red.opacity(0.8))
.clipShape(Circle())
}
}
.disabled(isDeleteLoading)
} else {
//
Color.clear
.frame(width: 44, height: 44)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 12)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color.black.opacity(0.4),
Color.black.opacity(0.2),
Color.clear
]),
startPoint: .top,
endPoint: .bottom
)
)
}
}
//#Preview {
// DetailView(
// store: Store(
// initialState: DetailFeature.State(
// moment: MomentsInfo(
// dynamicId: 1,
// uid: 123,
// nick: "Test User",
// avatar: "https://example.com/avatar.jpg",
// type: 1,
// content: "This is a test dynamic content",
// publishTime: Int(Date().timeIntervalSince1970 * 1000),
// likeCount: 10,
// isLike: false,
// worldId: 1,
// dynamicResList: [
// MomentsPicture(
// id: 1,
// resUrl: "https://example.com/image1.jpg",
// format: "jpg",
// width: 800,
// height: 600,
// resDuration: nil
// )
// ]
// )
// )
// ) {
// DetailFeature()
// }
// )
//}

View File

@@ -5,37 +5,41 @@ import Combine
struct EMailLoginView: View {
let store: StoreOf<EMailLoginFeature>
let onBack: () -> Void
@Binding var showEmailLogin: Bool
@Binding var showEmailLogin: Bool //
// 使@StateUI
@State private var email: String = ""
@State private var verificationCode: String = ""
@State private var codeCountdown: Int = 0
@State private var timerCancellable: AnyCancellable?
@FocusState private var focusedField: Field?
//
@State private var timerCancellable: AnyCancellable?
//
private var isLoginButtonEnabled: Bool {
return !store.isLoading && !email.isEmpty && !verificationCode.isEmpty
}
private var getCodeButtonText: String {
if codeCountdown > 0 {
return "\(codeCountdown)s"
} else {
return LocalizedString("email_login.get_code", comment: "")
}
}
private var isCodeButtonEnabled: Bool {
return !store.isCodeLoading && codeCountdown == 0 && !email.isEmpty
}
enum Field {
case email
case verificationCode
}
private var isLoginButtonEnabled: Bool {
return !store.isLoading && !email.isEmpty && !verificationCode.isEmpty
}
private var getCodeButtonText: String {
if store.isCodeLoading {
return ""
} else if codeCountdown > 0 {
return "\(codeCountdown)S"
} else {
return NSLocalizedString("email_login.get_code", comment: "")
}
}
private var isCodeButtonEnabled: Bool {
return !store.isCodeLoading && codeCountdown == 0
}
var body: some View {
WithViewStore(store, observe: { $0.loginStep }) { viewStore in
WithPerceptionTracking {
LoginContentView(
store: store,
onBack: onBack,
@@ -47,7 +51,7 @@ struct EMailLoginView: View {
getCodeButtonText: getCodeButtonText,
isCodeButtonEnabled: isCodeButtonEnabled
)
.onChange(of: viewStore.state) { newStep in
.onChange(of: store.loginStep) { newStep in
debugInfoSync("🔄 EMailLoginView: loginStep 变化为 \(newStep)")
if newStep == .completed {
debugInfoSync("✅ EMailLoginView: 登录成功,准备关闭自身")
@@ -147,116 +151,116 @@ private struct LoginContentView: View {
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer().frame(height: 60)
Text(NSLocalizedString("email_login.title", comment: ""))
Text(LocalizedString("email_login.title", comment: ""))
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
VStack(spacing: 24) {
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
VStack(spacing: 20) {
//
VStack(alignment: .leading, spacing: 8) {
HStack {
Image("email icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
Text(LocalizedString("email_login.email", comment: ""))
.font(.system(size: 16))
.foregroundColor(.white)
}
TextField("", text: $email)
.placeholder(when: email.isEmpty) {
Text(NSLocalizedString("placeholder.enter_email", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.emailAddress)
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.focused($focusedField, equals: .email)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
//
VStack(alignment: .leading, spacing: 8) {
HStack {
Image("id icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
Text(LocalizedString("email_login.verification_code", comment: ""))
.font(.system(size: 16))
.foregroundColor(.white)
}
HStack {
TextField("", text: $verificationCode)
.placeholder(when: verificationCode.isEmpty) {
Text(NSLocalizedString("placeholder.enter_code", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 16))
.keyboardType(.numberPad)
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.focused($focusedField, equals: .verificationCode)
.keyboardType(.numberPad)
Button(action: {
store.send(.getVerificationCodeTapped)
}) {
ZStack {
if store.isCodeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.7)
} else {
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
}
}
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color.white.opacity(isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
)
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(isCodeButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(8)
}
.disabled(!isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
.disabled(!isCodeButtonEnabled)
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
Spacer().frame(height: 60)
Button(action: {
store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
}) {
ZStack {
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0),
Color(red: 0.54, green: 0.31, blue: 1.0)
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isLoading ? NSLocalizedString("email_login.logging_in", comment: "") : NSLocalizedString("email_login.login_button", comment: ""))
.font(.system(size: 18, weight: .semibold))
//
Button(action: {
store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
}) {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
} else {
Text(LocalizedString("email_login.login", comment: ""))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
}
.frame(height: 56)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(8)
.disabled(!isLoginButtonEnabled)
.padding(.top, 20)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
}
.disabled(store.isLoading || email.isEmpty || verificationCode.isEmpty)
.opacity(isLoginButtonEnabled ? 1.0 : 0.5)
.padding(.horizontal, 32)
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
// API Loading
APILoadingEffectView()
}
}
}
}
.navigationBarHidden(true)
}
}

View File

@@ -1,7 +1,6 @@
import SwiftUI
import ComposableArchitecture
import PhotosUI
//import ImagePreviewPager
struct EditFeedView: View {
let onDismiss: () -> Void
@@ -16,284 +15,308 @@ struct EditFeedView: View {
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)
}
}
ZStack {
backgroundView
mainContent(geometry: geometry)
if store.isUploadingImages {
uploadingImagesOverlay(progress: store.imageUploadProgress)
} else if store.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
}
}
}
.navigationBarHidden(true)
.onAppear {
store.send(.clearError)
}
.onChange(of: store.shouldDismiss) {
if store.shouldDismiss {
onDismiss()
}
}
.photosPicker(
isPresented: Binding(
get: { store.showPhotosPicker },
set: { _ in store.send(.photosPickerDismissed) }
),
selection: Binding(
get: { store.selectedPhotoItems },
set: { store.send(.photosPickerItemsChanged($0)) }
),
maxSelectionCount: 9,
matching: .images
)
.alert("删除图片", isPresented: Binding(
get: { store.showDeleteImageAlert },
set: { _ in store.send(.deleteImageAlertDismissed) }
)) {
Button("删除", role: .destructive) {
if let indexToDelete = store.imageToDeleteIndex {
store.send(.removeImage(indexToDelete))
}
}
Button("取消", role: .cancel) {
store.send(.deleteImageAlertDismissed)
}
} message: {
Text("确定要删除这张图片吗?")
}
}
}
private var backgroundView: some View {
Color(hexString: "0C0527")
Color(hex: 0x0C0527)
.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)
)
private func mainContent(geometry: GeometryProxy) -> some View {
WithPerceptionTracking {
VStack(spacing: 0) {
topNavigationBar
ScrollView {
VStack(spacing: 20) {
textInputSection
imageSelectionSection
publishButton
}
.disabled(!viewStore.canPublish)
.padding(.horizontal, 20)
.padding(.bottom, 40)
}
}
}
.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)
}
private var topNavigationBar: some View {
WithPerceptionTracking {
HStack {
Button(action: {
store.send(.clearDismissFlag)
onDismiss()
}) {
Image(systemName: "xmark")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
}
}
.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))
Spacer()
Text("编辑动态")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: [Color(hexString: "A14AC6"), Color(hexString: "3B1EEB")],
startPoint: .leading,
endPoint: .trailing
)
.cornerRadius(28)
)
Spacer()
Color.clear
.frame(width: 44, height: 44)
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
}
private var textInputSection: some View {
WithPerceptionTracking {
VStack(alignment: .leading, spacing: 12) {
Text("分享你的想法...")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
TextEditor(text: Binding(
get: { store.content },
set: { store.send(.contentChanged($0)) }
))
.font(.system(size: 16))
.foregroundColor(.white)
.background(Color.clear)
.frame(minHeight: 120)
.padding(12)
.background(Color.white.opacity(0.1))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
HStack {
Spacer()
Text("\(store.content.count)/\(maxCount)")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
}
}
}
private var imageSelectionSection: some View {
WithPerceptionTracking {
VStack(alignment: .leading, spacing: 12) {
Text("添加图片")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
ImageGrid(
images: store.processedImages,
onRemoveImage: { index in
store.send(.showDeleteImageAlert(index))
},
onAddImage: {
store.send(.addImageButtonTapped)
}
)
}
}
}
private var publishButton: some View {
WithPerceptionTracking {
Button(action: {
store.send(.publishButtonTapped)
}) {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
} else {
Text("发布")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(store.content.isEmpty ? Color.gray : Color.blue)
.cornerRadius(12)
.disabled(store.isLoading || store.content.isEmpty)
}
}
private func uploadingImagesOverlay(progress: Double) -> some View {
WithPerceptionTracking {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.frame(width: 200)
Text("上传图片中... \(Int(progress * 100))%")
.font(.system(size: 16))
.foregroundColor(.white)
}
.padding(24)
.background(Color.black.opacity(0.8))
.cornerRadius(12)
}
.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 })
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.5)
}
}
}
}
// MARK: -
struct ImageGrid: View {
let images: [UIImage]
let onRemoveImage: (Int) -> Void
let onAddImage: () -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
var body: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
ImageGridItem(
image: image,
onRemove: { onRemoveImage(index) }
)
}
if images.count < 9 {
AddImageButton(onTap: onAddImage)
}
}
}
}
// MARK: -
struct ImageGridItem: View {
let image: UIImage
let onRemove: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 100)
.clipped()
.cornerRadius(8)
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.5))
.clipShape(Circle())
}
.padding(4)
}
}
}
// MARK: -
struct AddImageButton: View {
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack {
Image(systemName: "plus")
.font(.system(size: 24))
.foregroundColor(.white.opacity(0.7))
Text("添加")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.7))
}
.frame(height: 100)
.frame(maxWidth: .infinity)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
}

View File

@@ -1,132 +1,263 @@
import SwiftUI
import ComposableArchitecture
// MARK: - BackgroundView
struct BackgroundView: View {
var body: some View {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
}
}
// MARK: - TopBarView
struct TopBarView: View {
let onEditTapped: () -> Void
var body: some View {
ZStack {
HStack {
Spacer(minLength: 0)
Text(LocalizedString("feedList.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .center)
Spacer(minLength: 0)
}
HStack {
Spacer(minLength: 0)
Button(action: onEditTapped) {
Image("add icon")
.resizable()
.frame(width: 40, height: 40)
}
}
}
.padding(.horizontal, 20)
}
}
// MARK: - LoadingView
private struct FeedListLoadingView: View {
var body: some View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
}
}
// MARK: - ErrorView
struct ErrorView: View {
let error: String
var body: some View {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.top, 20)
}
}
// MARK: - EmptyView
struct EmptyView: View {
var body: some View {
Text(LocalizedString("feedList.empty", comment: "暂无动态"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 20)
}
}
// MARK: - MomentCardView
struct MomentCardView: View {
let moment: MomentsInfo
let allMoments: [MomentsInfo]
let index: Int
let onImageTap: ([String], Int) -> Void
let onTap: () -> Void
let onLikeTap: (Int, Int, Int, Int) -> Void
let onLoadMore: () -> Void
let isLastItem: Bool
let hasMore: Bool
let isLoadingMore: Bool
let isLikeLoading: Bool
var body: some View {
VStack(spacing: 16) {
OptimizedDynamicCardView(
moment: moment,
allMoments: allMoments,
currentIndex: index,
onImageTap: onImageTap,
onLikeTap: onLikeTap,
onCardTap: onTap,
isDetailMode: false,
isLikeLoading: isLikeLoading
)
//
if isLastItem && hasMore && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
onLoadMore()
}
}
}
}
}
// MARK: - MomentsListView
struct MomentsListView: View {
let moments: [MomentsInfo]
let hasMore: Bool
let isLoadingMore: Bool
let onImageTap: ([String], Int) -> Void
let onMomentTap: (MomentsInfo) -> Void
let onLikeTap: (Int, Int, Int, Int) -> Void
let onLoadMore: () -> Void
let onRefresh: () -> Void
let likeLoadingDynamicIds: Set<Int>
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(Array(moments.enumerated()), id: \.element.dynamicId) { index, moment in
MomentCardView(
moment: moment,
allMoments: moments,
index: index,
onImageTap: onImageTap,
onTap: {
onMomentTap(moment)
},
onLikeTap: onLikeTap,
onLoadMore: onLoadMore,
isLastItem: index == moments.count - 1,
hasMore: hasMore,
isLoadingMore: isLoadingMore,
isLikeLoading: likeLoadingDynamicIds.contains(moment.dynamicId)
)
}
//
if isLoadingMore {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 8)
}
//
Color.clear.frame(height: 120)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 20)
}
.refreshable {
onRefresh()
}
}
}
// MARK: - FeedListContentView
struct FeedListContentView: View {
let store: StoreOf<FeedListFeature>
@Binding var previewItem: PreviewItem?
@Binding var previewCurrentIndex: Int
var body: some View {
if store.isLoading {
FeedListLoadingView()
} else if let error = store.error {
ErrorView(error: error)
} else if store.moments.isEmpty {
EmptyView()
} else {
MomentsListView(
moments: store.moments,
hasMore: store.hasMore,
isLoadingMore: store.isLoadingMore,
onImageTap: { images, tappedIndex in
previewCurrentIndex = tappedIndex
previewItem = PreviewItem(images: images, index: tappedIndex)
},
onMomentTap: { moment in
store.send(.showDetail(moment))
},
onLikeTap: { dynamicId, uid, likedUid, worldId in
store.send(.likeDynamic(dynamicId, uid, likedUid, worldId))
},
onLoadMore: {
store.send(.loadMore)
},
onRefresh: {
store.send(.reload)
},
likeLoadingDynamicIds: store.likeLoadingDynamicIds
)
}
}
}
struct FeedListView: View {
let store: StoreOf<FeedListFeature>
//
@State private var previewItem: PreviewItem? = nil
@State private var previewCurrentIndex: Int = 0
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
GeometryReader { geometry in
WithViewStore(store, observe: { $0 }) { viewStore in
GeometryReader { geometry in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
BackgroundView()
VStack(alignment: .center, spacing: 0) {
//
ZStack {
HStack {
Spacer(minLength: 0)
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .center)
Spacer(minLength: 0)
}
HStack {
Spacer(minLength: 0)
Button(action: {
viewStore.send(.editFeedButtonTapped)
}) {
Image("add icon")
.resizable()
.frame(width: 40, height: 40)
}
}
TopBarView {
store.send(.editFeedButtonTapped)
}
.padding(.horizontal, 20)
//
Image("Volume")
.frame(width: 56, height: 41)
.padding(.top, 16)
Text(NSLocalizedString("feedList.slogan", comment: "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable."))
Text(LocalizedString("feedList.slogan", comment: "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable."))
.font(.system(size: 16))
.multilineTextAlignment(.leading)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.bottom, 30)
//
if viewStore.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
} else if let error = viewStore.error {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.top, 20)
} else if viewStore.moments.isEmpty {
Text(NSLocalizedString("feedList.empty", comment: "暂无动态"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 20)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 16) {
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: viewStore.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
}
)
//
if index == viewStore.moments.count - 1, viewStore.hasMore, !viewStore.isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
viewStore.send(.loadMore)
}
}
}
//
if viewStore.isLoadingMore {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 8)
}
//
Color.clear.frame(height: 120)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 20)
}
}
.refreshable {
viewStore.send(.reload)
}
}
//
FeedListContentView(
store: store,
previewItem: $previewItem,
previewCurrentIndex: $previewCurrentIndex
)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
}
}
.onAppear {
viewStore.send(.onAppear)
store.send(.onAppear)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("reloadFeedList"))) { _ in
viewStore.send(.reload)
.refreshable {
store.send(.reload)
}
.sheet(isPresented: viewStore.binding(
get: \.isEditFeedPresented,
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
)) {
//
.sheet(isPresented: viewStore.binding(get: \.isEditFeedPresented, send: { _ in .editFeedDismissed })) {
EditFeedView(
onDismiss: {
viewStore.send(.editFeedDismissed)
store.send(.editFeedDismissed)
},
store: Store(
initialState: EditFeedFeature.State()
@@ -135,12 +266,23 @@ struct FeedListView: View {
}
)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
previewItem = nil
//
.navigationDestination(isPresented: viewStore.binding(get: \.showDetail, send: { _ in .detailDismissed })) {
if let selectedMoment = viewStore.selectedMoment {
DetailView(
store: Store(
initialState: DetailFeature.State(moment: selectedMoment)
) {
DetailFeature()
}
)
}
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images as [String], currentIndex: $previewCurrentIndex) {
previewItem = nil
}
}
}
}

View File

@@ -1,636 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct FeedTopBarView: View {
let store: StoreOf<FeedFeature>
let onShowCreateFeed: () -> Void
var body: some View {
WithPerceptionTracking {
HStack {
Spacer()
Text(NSLocalizedString("feed.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
Spacer()
Button(action: {
// showEditFeed = true //
}) {
Image("add icon")
.frame(width: 36, height: 36)
}
}
.padding(.horizontal, 20)
}
}
}
struct FeedMomentsListView: View {
let store: StoreOf<FeedFeature>
var body: some View {
WithPerceptionTracking {
LazyVStack(spacing: 16) {
if store.moments.isEmpty {
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.6))
Text(NSLocalizedString("feed.empty", comment: "No moments yet"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
if let error = store.error {
Text(String(format: NSLocalizedString("feed.error", comment: "Error: %@"), error))
.font(.system(size: 12))
.foregroundColor(.red.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
//
if store.error != nil {
Button(action: {
store.send(.retryLoad)
}) {
Text(NSLocalizedString("feed.retry", comment: "Retry"))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Color.blue.opacity(0.8))
.cornerRadius(8)
}
.padding(.top, 8)
}
}
.padding(.top, 40)
} else {
ForEach(Array(store.moments.enumerated()), id: \.element.dynamicId) { index, moment in
WithPerceptionTracking {
Text(moment.avatar)
// OptimizedDynamicCardView(
// moment: moment,
// allMoments: store.moments,
// currentIndex: index
// )
.onAppear {
//
if index == store.moments.count - 1 && store.hasMoreData && !store.isLoading {
store.send(.loadMoreMoments)
}
}
}
}
//
if store.isLoading && !store.moments.isEmpty {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
Text(NSLocalizedString("feed.loadingMore", comment: "Loading more..."))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.top, 20)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 20) //
}
}
}
struct FeedView: View {
let store: StoreOf<FeedFeature>
let onShowCreateFeed: () -> Void
@State private var showEditFeed = false
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
// - HomeView
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
VStack(spacing: 0) {
//
VStack(spacing: 20) {
FeedTopBarView(store: store, onShowCreateFeed: onShowCreateFeed)
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundColor(.red)
.padding(.top, 40)
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
.font(.system(size: 16))
.multilineTextAlignment(.center)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.bottom, 30)
}
// .padding(.top, 60) //
// -
ScrollView {
FeedMomentsListView(store: store)
.padding(.bottom, 20) //
}
.refreshable {
//
await withCheckedContinuation { continuation in
store.send(.refresh)
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
continuation.resume()
}
}
}
}
}
}
.onAppear {
store.send(.onAppear)
}
.sheet(isPresented: $showEditFeed) {
EditFeedView(
onDismiss: {
showEditFeed = false
},
store: Store(
initialState: EditFeedFeature.State()
) {
EditFeedFeature()
}
)
}
}
}
}
// MARK: -
//struct OptimizedDynamicCardView: View {
// let moment: MomentsInfo
// let allMoments: [MomentsInfo]
// let currentIndex: Int
//
// var body: some View {
// WithPerceptionTracking{
// VStack(alignment: .leading, spacing: 12) {
// //
// HStack {
// // 使
// CachedAsyncImage(url: moment.avatar) { image in
// image
// .resizable()
// .aspectRatio(contentMode: .fill)
// } placeholder: {
// Circle()
// .fill(Color.gray.opacity(0.3))
// .overlay(
// Text(String(moment.nick.prefix(1)))
// .font(.system(size: 16, weight: .medium))
// .foregroundColor(.white)
// )
// }
// .frame(width: 40, height: 40)
// .clipShape(Circle())
//
// VStack(alignment: .leading, spacing: 2) {
// Text(moment.nick)
// .font(.system(size: 16, weight: .medium))
// .foregroundColor(.white)
//
// Text(formatTime(moment.publishTime))
// .font(.system(size: 12))
// .foregroundColor(.white.opacity(0.6))
// }
//
// Spacer()
//
// // VIP
// if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
// Text("VIP\(vipLevel)")
// .font(.system(size: 10, weight: .bold))
// .foregroundColor(.yellow)
// .padding(.horizontal, 6)
// .padding(.vertical, 2)
// .background(Color.yellow.opacity(0.2))
// .cornerRadius(4)
// }
// }
//
// //
// if !moment.content.isEmpty {
// Text(moment.content)
// .font(.system(size: 14))
// .foregroundColor(.white.opacity(0.9))
// .multilineTextAlignment(.leading)
// }
//
// //
// if let images = moment.dynamicResList, !images.isEmpty {
// OptimizedImageGrid(images: images)
// }
//
// //
// HStack(spacing: 20) {
// Button(action: {}) {
// HStack(spacing: 4) {
// Image(systemName: "message")
// .font(.system(size: 16))
// Text("\(moment.commentCount)")
// .font(.system(size: 14))
// }
// .foregroundColor(.white.opacity(0.8))
// }
//
// Button(action: {}) {
// HStack(spacing: 4) {
// Image(systemName: moment.isLike ? "heart.fill" : "heart")
// .font(.system(size: 16))
// Text("\(moment.likeCount)")
// .font(.system(size: 14))
// }
// .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
// }
//
// Spacer()
// }
// .padding(.top, 8)
// }
// }
// .padding(16)
// .background(
// Color.white.opacity(0.1)
// .cornerRadius(12)
// )
// .onAppear {
// //
// preloadNearbyImages()
// }
// }
//
// private func formatTime(_ timestamp: Int) -> String {
// let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
// let formatter = DateFormatter()
// formatter.locale = Locale(identifier: "zh_CN")
//
// let now = Date()
// let interval = now.timeIntervalSince(date)
//
// if interval < 60 {
// return ""
// } else if interval < 3600 {
// return "\(Int(interval / 60))"
// } else if interval < 86400 {
// return "\(Int(interval / 3600))"
// } else {
// formatter.dateFormat = "MM-dd HH:mm"
// return formatter.string(from: date)
// }
// }
//
// private func preloadNearbyImages() {
// var urlsToPreload: [String] = []
//
// // 2
// let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
//
// for index in preloadRange {
// let moment = allMoments[index]
//
// //
// urlsToPreload.append(moment.avatar)
//
// //
// if let images = moment.dynamicResList {
// urlsToPreload.append(contentsOf: images.map { $0.resUrl })
// }
// }
//
// //
// ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
// }
//}
// MARK: -
//struct OptimizedImageGrid: View {
// let images: [MomentsPicture]
//
// var body: some View {
// GeometryReader { geometry in
// let availableWidth = max(geometry.size.width, 1) // 0
// let spacing: CGFloat = 8
//
// // availableWidth
// if availableWidth < 10 {
// Color.clear.frame(height: 1)
// } else {
// switch images.count {
// case 1:
// //
// let imageSize: CGFloat = min(availableWidth * 0.6, 200)
// HStack {
// Spacer()
// SquareImageView(image: images[0], size: imageSize)
// Spacer()
// }
// case 2:
// //
// let imageSize: CGFloat = (availableWidth - spacing) / 2
// HStack(spacing: spacing) {
// SquareImageView(image: images[0], size: imageSize)
// SquareImageView(image: images[1], size: imageSize)
// }
// case 3:
// //
// let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
// HStack(spacing: spacing) {
// ForEach(images.prefix(3), id: \.id) { image in
// SquareImageView(image: image, size: imageSize)
// }
// }
// default:
// // 9
// let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
// let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
// LazyVGrid(columns: columns, spacing: spacing) {
// ForEach(images.prefix(9), id: \.id) { image in
// SquareImageView(image: image, size: imageSize)
// }
// }
// }
// }
// }
// .frame(height: calculateGridHeight())
// }
//
// private func calculateGridHeight() -> CGFloat {
// switch images.count {
// case 1:
// return 200 //
// case 2:
// return 120 //
// case 3:
// return 100 //
// case 4...6:
// return 216 // 2 ( * 2 + )
// default:
// return 340 // 3 ( * 3 + + )
// }
// }
//}
// MARK: -
//struct SquareImageView: View {
// let image: MomentsPicture
// let size: CGFloat
//
// var body: some View {
// let safeSize = size.isFinite && size > 0 ? size : 100 //
// CachedAsyncImage(url: image.resUrl) { imageView in
// imageView
// .resizable()
// .aspectRatio(contentMode: .fill)
// } placeholder: {
// Rectangle()
// .fill(Color.gray.opacity(0.3))
// .overlay(
// ProgressView()
// .progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
// .scaleEffect(0.8)
// )
// }
// .frame(width: safeSize, height: safeSize)
// .clipped()
// .cornerRadius(8)
// }
//}
// MARK: -
//struct RealDynamicCardView: View {
// let moment: MomentsInfo
//
// var body: some View {
// VStack(alignment: .leading, spacing: 12) {
// //
// HStack {
// AsyncImage(url: URL(string: moment.avatar)) { image in
// image
// .resizable()
// .aspectRatio(contentMode: .fill)
// } placeholder: {
// Circle()
// .fill(Color.gray.opacity(0.3))
// .overlay(
// Text(String(moment.nick.prefix(1)))
// .font(.system(size: 16, weight: .medium))
// .foregroundColor(.white)
// )
// }
// .frame(width: 40, height: 40)
// .clipShape(Circle())
//
// VStack(alignment: .leading, spacing: 2) {
// Text(moment.nick)
// .font(.system(size: 16, weight: .medium))
// .foregroundColor(.white)
//
// Text(formatTime(moment.publishTime))
// .font(.system(size: 12))
// .foregroundColor(.white.opacity(0.6))
// }
//
// Spacer()
//
// // VIP
// if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
// Text("VIP\(vipLevel)")
// .font(.system(size: 10, weight: .bold))
// .foregroundColor(.yellow)
// .padding(.horizontal, 6)
// .padding(.vertical, 2)
// .background(Color.yellow.opacity(0.2))
// .cornerRadius(4)
// }
// }
//
// //
// if !moment.content.isEmpty {
// Text(moment.content)
// .font(.system(size: 14))
// .foregroundColor(.white.opacity(0.9))
// .multilineTextAlignment(.leading)
// }
//
// //
// if let images = moment.dynamicResList, !images.isEmpty {
// LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
// ForEach(images.prefix(9), id: \.id) { image in
// AsyncImage(url: URL(string: image.resUrl)) { imageView in
// imageView
// .resizable()
// .aspectRatio(contentMode: .fill)
// } placeholder: {
// Rectangle()
// .fill(Color.gray.opacity(0.3))
// .overlay(
// ProgressView()
// .progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
// )
// }
// .frame(height: 100)
// .clipped()
// .cornerRadius(8)
// }
// }
// }
//
// //
// HStack(spacing: 20) {
// Button(action: {}) {
// HStack(spacing: 4) {
// Image(systemName: "message")
// .font(.system(size: 16))
// Text("\(moment.commentCount)")
// .font(.system(size: 14))
// }
// .foregroundColor(.white.opacity(0.8))
// }
//
// Button(action: {}) {
// HStack(spacing: 4) {
// Image(systemName: moment.isLike ? "heart.fill" : "heart")
// .font(.system(size: 16))
// Text("\(moment.likeCount)")
// .font(.system(size: 14))
// }
// .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
// }
//
// Spacer()
// }
// .padding(.top, 8)
// }
// .padding(16)
// .background(
// Color.white.opacity(0.1)
// .cornerRadius(12)
// )
// }
//
// private func formatTime(_ timestamp: Int) -> String {
// let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
// let formatter = DateFormatter()
// formatter.locale = Locale(identifier: "zh_CN")
//
// let now = Date()
// let interval = now.timeIntervalSince(date)
//
// if interval < 60 {
// return ""
// } else if interval < 3600 {
// return "\(Int(interval / 60))"
// } else if interval < 86400 {
// return "\(Int(interval / 3600))"
// } else {
// formatter.dateFormat = "MM-dd HH:mm"
// return formatter.string(from: date)
// }
// }
//}
// MARK: -
//struct DynamicCardView: View {
// let index: Int
//
// var body: some View {
// VStack(alignment: .leading, spacing: 12) {
// //
// HStack {
// Circle()
// .fill(Color.gray.opacity(0.3))
// .frame(width: 40, height: 40)
// .overlay(
// Text("U\(index + 1)")
// .font(.system(size: 16, weight: .medium))
// .foregroundColor(.white)
// )
//
// VStack(alignment: .leading, spacing: 2) {
// Text("\(index + 1)")
// .font(.system(size: 16, weight: .medium))
// .foregroundColor(.white)
//
// Text("2")
// .font(.system(size: 12))
// .foregroundColor(.white.opacity(0.6))
// }
//
// Spacer()
// }
//
// //
// Text("")
// .font(.system(size: 14))
// .foregroundColor(.white.opacity(0.9))
// .multilineTextAlignment(.leading)
//
// //
// LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
// ForEach(0..<3) { imageIndex in
// Rectangle()
// .fill(Color.gray.opacity(0.3))
// .aspectRatio(1, contentMode: .fit)
// .overlay(
// Image(systemName: "photo")
// .foregroundColor(.white.opacity(0.6))
// )
// }
// }
//
// //
// HStack(spacing: 20) {
// Button(action: {}) {
// HStack(spacing: 4) {
// Image(systemName: "message")
// .font(.system(size: 16))
// Text("354")
// .font(.system(size: 14))
// }
// .foregroundColor(.white.opacity(0.8))
// }
//
// Button(action: {}) {
// HStack(spacing: 4) {
// Image(systemName: "heart")
// .font(.system(size: 16))
// Text("354")
// .font(.system(size: 14))
// }
// .foregroundColor(.white.opacity(0.8))
// }
//
// Spacer()
// }
// .padding(.top, 8)
// }
// .padding(16)
// .background(
// Color.white.opacity(0.1)
// .cornerRadius(12)
// )
// }
//}
//#Preview {
// FeedView(
// store: Store(initialState: FeedFeature.State()) {
// FeedFeature()
// }
// )
//}

View File

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

View File

@@ -2,17 +2,174 @@ import SwiftUI
import ComposableArchitecture
import Perception
// MARK: -
struct IDLoginBackgroundView: View {
var body: some View {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
}
}
// MARK: -
struct IDLoginHeaderView: View {
let onBack: () -> Void
var body: some View {
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
}
// MARK: -
struct IDLoginInputFieldView: View {
let iconName: String
let title: String
let text: Binding<String>
let onChange: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
Text(title)
.font(.system(size: 16))
.foregroundColor(.white)
}
TextField("", text: text)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 16))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.onChange(of: text.wrappedValue) { newValue in
onChange(newValue)
}
}
}
}
// MARK: -
struct IDLoginPasswordFieldView: View {
let password: Binding<String>
let isPasswordVisible: Binding<Bool>
let onChange: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image("email icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
Text(LocalizedString("id_login.password", comment: ""))
.font(.system(size: 16))
.foregroundColor(.white)
}
HStack {
Group {
if isPasswordVisible.wrappedValue {
TextField("", text: password)
.textFieldStyle(PlainTextFieldStyle())
} else {
SecureField("", text: password)
.textFieldStyle(PlainTextFieldStyle())
}
}
Button(action: {
isPasswordVisible.wrappedValue.toggle()
}) {
Image(systemName: isPasswordVisible.wrappedValue ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
}
}
.font(.system(size: 16))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.onChange(of: password.wrappedValue) { newValue in
onChange(newValue)
}
}
}
}
// MARK: -
struct IDLoginButtonView: View {
let isLoading: Bool
let isEnabled: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
Group {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
} else {
Text(LocalizedString("id_login.login", comment: ""))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(isEnabled ? Color.blue : Color.gray)
.cornerRadius(8)
.disabled(!isEnabled)
.padding(.top, 20)
}
}
// MARK: -
struct IDLoginErrorView: View {
let errorMessage: String?
var body: some View {
if let errorMessage = errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
}
}
// MARK: -
struct IDLoginView: View {
let store: StoreOf<IDLoginFeature>
let onBack: () -> Void
@Binding var showIDLogin: Bool //
@Binding var showIDLogin: Bool
// 使@StateUI
@State private var userID: String = ""
@State private var password: String = ""
@State private var isPasswordVisible: Bool = false
// - LoginView
//
@State private var showRecoverPassword: Bool = false
//
@@ -21,174 +178,79 @@ struct IDLoginView: View {
}
var body: some View {
WithViewStore(store, observe: { $0.loginStep }) { viewStore in
WithPerceptionTracking {
GeometryReader { geometry in
WithPerceptionTracking {
ZStack {
// - 使"bg"
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
//
IDLoginBackgroundView()
VStack(spacing: 0) {
//
HStack {
Button(action: {
onBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
IDLoginHeaderView(onBack: onBack)
Spacer()
.frame(height: 60)
//
Text(NSLocalizedString("id_login.title", comment: ""))
Text(LocalizedString("id_login.title", comment: ""))
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
//
VStack(spacing: 24) {
// ID
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $userID) // 使SwiftUI
.placeholder(when: userID.isEmpty) {
Text(NSLocalizedString("placeholder.enter_id", comment: ""))
.foregroundColor(.white.opacity(0.6))
VStack(spacing: 20) {
// ID
IDLoginInputFieldView(
iconName: "id icon",
title: LocalizedString("id_login.user_id", comment: ""),
text: $userID,
onChange: { newValue in
store.send(.userIDChanged(newValue))
}
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.numberPad)
}
)
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
if isPasswordVisible {
TextField("", text: $password) // 使SwiftUI
.placeholder(when: password.isEmpty) {
Text(NSLocalizedString("placeholder.enter_password", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
} else {
SecureField("", text: $password) // 使SwiftUI
.placeholder(when: password.isEmpty) {
Text(NSLocalizedString("placeholder.enter_password", comment: ""))
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
}
Button(action: {
isPasswordVisible.toggle()
}) {
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
.font(.system(size: 18))
}
IDLoginPasswordFieldView(
password: $password,
isPasswordVisible: $isPasswordVisible,
onChange: { newValue in
store.send(.passwordChanged(newValue))
}
)
//
HStack {
Spacer()
Button(action: {
showRecoverPassword = true
}) {
Text(LocalizedString("id_login.forgot_password", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
// Forgot Password
HStack {
//
IDLoginButtonView(
isLoading: store.isLoading,
isEnabled: isLoginButtonEnabled,
onTap: {
store.send(.loginButtonTapped(userID: userID, password: password))
}
)
//
IDLoginErrorView(errorMessage: store.errorMessage)
Spacer()
Button(action: {
showRecoverPassword = true
}) {
Text(NSLocalizedString("id_login.forgot_password", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
}
.padding(.horizontal, 32)
.padding(.top, 16)
Spacer()
.frame(height: 60)
//
Button(action: {
// action
store.send(.loginButtonTapped(userID: userID, password: password))
}) {
ZStack {
//
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isLoading ? NSLocalizedString("id_login.logging_in", comment: "") : NSLocalizedString("id_login.login_button", comment: ""))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(store.isLoading || userID.isEmpty || password.isEmpty)
.opacity(isLoginButtonEnabled ? 1.0 : 0.5) // 50%
.padding(.horizontal, 32)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
// API Loading
APILoadingEffectView()
}
}
}
}
.navigationBarHidden(true)
// 使 LoginView navigationDestination
.navigationDestination(isPresented: $showRecoverPassword) {
WithPerceptionTracking {
RecoverPasswordView(
@@ -212,13 +274,11 @@ struct IDLoginView: View {
isPasswordVisible = store.isPasswordVisible
#if DEBUG
//
debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
#endif
}
}
//
.onChange(of: viewStore.state) { newStep in
.onChange(of: store.loginStep) { newStep in
debugInfoSync("🔄 IDLoginView: loginStep 变化为 \(newStep)")
if newStep == .completed {
debugInfoSync("✅ IDLoginView: 登录成功,准备关闭自身")

View File

@@ -9,6 +9,16 @@ struct LanguageSettingsView: View {
// 使 TCA API
@Dependency(\.apiService) private var apiService
//
@State private var cosTokenData: TcTokenData?
//
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
init(isPresented: Binding<Bool> = .constant(true)) {
self._isPresented = isPresented
}
@@ -33,7 +43,7 @@ struct LanguageSettingsView: View {
Section {
HStack {
Text("当前语言 / Current Language")
Text(LocalizedString("language_settings.current_language", comment: ""))
.font(.body)
Spacer()
@@ -43,7 +53,85 @@ struct LanguageSettingsView: View {
.foregroundColor(.blue)
}
} header: {
Text("语言信息 / Language Info")
Text(LocalizedString("language_settings.language_info", comment: ""))
.font(.caption)
.foregroundColor(.secondary)
}
//
Section {
VStack(alignment: .leading, spacing: 8) {
Text(LocalizedString("language_settings.test_area", comment: ""))
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
Text(LocalizedString("language_settings.test_region", comment: ""))
.font(.subheadline)
.foregroundColor(.secondary)
Text("应用标题: \(LocalizedString("login.app_title", comment: ""))")
.font(.caption)
Text("登录按钮: \(LocalizedString("login.id_login", comment: ""))")
.font(.caption)
Text("当前语言代码: \(localizationManager.currentLanguage.rawValue)")
.font(.caption)
.foregroundColor(.blue)
}
.padding(.leading, 8)
}
} header: {
Text(LocalizedString("language_settings.test_region", comment: ""))
.font(.caption)
.foregroundColor(.secondary)
}
// COS Token
Section {
VStack(alignment: .leading, spacing: 8) {
Button(LocalizedString("language_settings.test_cos_token", comment: "")) {
Task {
await testCOToken()
}
}
.buttonStyle(.borderedProminent)
if let tokenData = cosTokenData {
VStack(alignment: .leading, spacing: 4) {
Text(LocalizedString("language_settings.token_success", comment: ""))
.font(.headline)
.foregroundColor(.green)
Text(String(format: LocalizedString("language_settings.bucket", comment: ""), tokenData.bucket))
.font(.caption)
Text(String(format: LocalizedString("language_settings.region", comment: ""), tokenData.region))
.font(.caption)
Text(String(format: LocalizedString("language_settings.app_id", comment: ""), tokenData.appId))
.font(.caption)
Text(String(format: LocalizedString("language_settings.custom_domain", comment: ""), tokenData.customDomain))
.font(.caption)
Text(String(format: LocalizedString("language_settings.accelerate_status", comment: ""),
tokenData.accelerate ?
LocalizedString("language_settings.accelerate_enabled", comment: "") :
LocalizedString("language_settings.accelerate_disabled", comment: "")))
.font(.caption)
Text(String(format: LocalizedString("language_settings.expiration_date", comment: ""), formatDate(tokenData.expirationDate)))
.font(.caption)
Text(String(format: LocalizedString("language_settings.remaining_time", comment: ""), tokenData.remainingTime))
.font(.caption)
}
.padding(.leading, 8)
}
}
} header: {
Text(LocalizedString("language_settings.test_region", comment: ""))
.font(.caption)
.foregroundColor(.secondary)
}
@@ -69,7 +157,7 @@ struct LanguageSettingsView: View {
Text("应用ID: \(tokenData.appId)")
Text("自定义域名: \(tokenData.customDomain)")
Text("加速: \(tokenData.accelerate ? "启用" : "禁用")")
Text("过期时间: \(tokenData.expirationDate, style: .date)")
Text("过期时间: \(formatDate(tokenData.expirationDate))")
Text("剩余时间: \(tokenData.remainingTime)")
}
.font(.caption)
@@ -80,7 +168,7 @@ struct LanguageSettingsView: View {
}
#endif
}
.navigationTitle("语言设置 / Language")
.navigationTitle(LocalizedString("language_settings.title", comment: ""))
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.onAppear {
@@ -95,19 +183,19 @@ struct LanguageSettingsView: View {
}
private func testCOToken() async {
// do {
let token = await cosManager.getToken(apiService: apiService)
if let token = token {
print("✅ Token 测试成功")
print(" - 存储桶: \(token.bucket)")
print(" - 地域: \(token.region)")
print(" - 剩余时间: \(token.remainingTime)")
} else {
print("❌ Token 测试失败: 未能获取 Token")
}
// } catch {
// print(" Token : \(error.localizedDescription)")
// }
let token = await cosManager.getToken(apiService: apiService)
if let token = token {
print("✅ Token 测试成功")
print(" - 存储桶: \(token.bucket)")
print(" - 地域: \(token.region)")
print(" - 剩余时间: \(token.remainingTime)")
//
cosTokenData = token
} else {
print("❌ Token 测试失败: 未能获取 Token")
cosTokenData = nil
}
}
}

View File

@@ -12,154 +12,53 @@ struct ImageHeightPreferenceKey: PreferenceKey {
struct LoginView: View {
let store: StoreOf<LoginFeature>
let onLoginSuccess: () -> Void //
@State private var topImageHeight: CGFloat = 120 //
// @ObservedObject private var localizationManager = LocalizationManager.shared
@State private var showLanguageSettings = false
@State private var isAgreedToTerms = true
@State private var showUserAgreement = false
@State private var showPrivacyPolicy = false
@State private var showIDLogin = false // 使SwiftUI@State
@State private var showEmailLogin = false //
let onLoginSuccess: () -> Void
// 使@StateUI
@State private var showIDLogin: Bool = false
@State private var showEmailLogin: Bool = false
@State private var showLanguageSettings: Bool = false
@State private var showUserAgreement: Bool = false
@State private var showPrivacyPolicy: Bool = false
var body: some View {
WithViewStore(self.store, observe: { $0.isAnyLoginCompleted }) { viewStore in
NavigationStack {
GeometryReader { geometry in
WithPerceptionTracking {
ZStack {
// 使 splash
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
// "top"
ZStack {
Image("top")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.padding(.top, -100)
.background(
GeometryReader { topImageGeometry in
Color.clear.preference(key: ImageHeightPreferenceKey.self, value: topImageGeometry.size.height)
}
)
// E-PARTI "top"20
HStack {
Text(NSLocalizedString("login.app_title", comment: ""))
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
.foregroundColor(.white)
.padding(.leading, 20)
Spacer()
}
.padding(.top, max(0, topImageHeight - 100)) // top - 140
// - Debug
#if DEBUG
VStack {
HStack {
Spacer()
Button(action: {
showLanguageSettings = true
}) {
Image(systemName: "globe")
.frame(width: 40, height: 40)
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
.padding(.trailing, 16)
}
Spacer()
}
#endif
VStack(spacing: 24) {
// ID Login
LoginButton(
iconName: "person.circle.fill",
iconColor: .green,
title: NSLocalizedString("login.id_login", comment: "")
) {
showIDLogin = true // SwiftUI
}
// Email Login
LoginButton(
iconName: "envelope.fill",
iconColor: .blue,
title: NSLocalizedString("login.email_login", comment: "")
) {
showEmailLogin = true //
}
}.padding(.top, max(0, topImageHeight+140))
}
.onPreferenceChange(ImageHeightPreferenceKey.self) { imageHeight in
topImageHeight = imageHeight
}
// 使"top"40pt
Spacer()
.frame(height: 120)
//
UserAgreementView(
isAgreed: $isAgreedToTerms,
onUserServiceTapped: {
showUserAgreement = true
},
onPrivacyPolicyTapped: {
showPrivacyPolicy = true
}
)
.padding(.horizontal, 28)
.padding(.bottom, 140)
}
// NavigationLink navigationDestination
}
}
NavigationStack {
GeometryReader { geometry in
ZStack {
backgroundView
mainContentView(geometry: geometry)
APILoadingEffectView()
}
}
.navigationBarHidden(true)
.navigationDestination(isPresented: $showIDLogin) {
IDLoginView(
store: store.scope(
state: \.idLoginState,
action: \.idLogin
),
onBack: {
showIDLogin = false
},
showIDLogin: $showIDLogin
)
.navigationBarHidden(true)
}
.navigationDestination(isPresented: $showEmailLogin) {
EMailLoginView(
store: store.scope(
state: \.emailLoginState,
action: \.emailLogin
),
onBack: {
showEmailLogin = false
},
showEmailLogin: $showEmailLogin
)
.navigationBarHidden(true)
// iOS 16 navigationDestination
.navigationDestination(isPresented: $showIDLogin) {
WithPerceptionTracking {
IDLoginView(
store: store.scope(
state: \.idLoginState,
action: \.idLogin
),
onBack: {
showIDLogin = false
},
showIDLogin: $showIDLogin // Binding
)
.navigationBarHidden(true)
}
}
.navigationDestination(isPresented: $showEmailLogin) {
WithPerceptionTracking {
EMailLoginView(
store: store.scope(
state: \.emailLoginState,
action: \.emailLogin
),
onBack: {
showEmailLogin = false
},
showEmailLogin: $showEmailLogin // Binding
)
.navigationBarHidden(true)
}
}
// HomeView navigationDestination
}
.sheet(isPresented: $showLanguageSettings) {
WithPerceptionTracking {
LanguageSettingsView(isPresented: $showLanguageSettings)
}
LanguageSettingsView(isPresented: $showLanguageSettings)
}
.webView(
isPresented: $showUserAgreement,
@@ -169,26 +68,122 @@ struct LoginView: View {
isPresented: $showPrivacyPolicy,
url: APIConfiguration.webURL(for: .privacyPolicy)
)
//
.onChange(of: viewStore.state) { completed in
if completed {
.onChange(of: store.isAnyLoginCompleted) {
if store.isAnyLoginCompleted {
onLoginSuccess()
}
}
// showIDLogin
.onChange(of: showIDLogin) { newValue in
if newValue == false && viewStore.state {
.onChange(of: showIDLogin) {
if showIDLogin == false && store.isAnyLoginCompleted {
onLoginSuccess()
}
}
// showEmailLogin
.onChange(of: showEmailLogin) { newValue in
if newValue == false && viewStore.state {
.onChange(of: showEmailLogin) {
if showEmailLogin == false && store.isAnyLoginCompleted {
onLoginSuccess()
}
}
}
}
// MARK: -
private var backgroundView: some View {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
}
private func mainContentView(geometry: GeometryProxy) -> some View {
VStack(spacing: 0) {
topSection(geometry: geometry)
bottomSection
}
}
private func topSection(geometry: GeometryProxy) -> some View {
ZStack {
Image("top")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.padding(.top, -100)
.background(
GeometryReader { topImageGeometry in
Color.clear.preference(key: ImageHeightPreferenceKey.self, value: topImageGeometry.size.height)
}
)
HStack {
Text(LocalizedString("login.app_title", comment: ""))
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
.foregroundColor(.white)
.padding(.leading, 20)
Spacer()
}
.padding(.top, 100) //
}
}
private var bottomSection: some View {
VStack(spacing: 20) {
loginButtons
bottomButtons
}
.padding(.horizontal, 28)
.padding(.bottom, 140)
}
private var loginButtons: some View {
VStack(spacing: 20) {
LoginButton(
iconName: "person.circle",
iconColor: .blue,
title: LocalizedString("login.id_login", comment: ""),
action: {
showIDLogin = true
}
)
LoginButton(
iconName: "envelope",
iconColor: .green,
title: LocalizedString("login.email_login", comment: ""),
action: {
showEmailLogin = true
}
)
}
}
private var bottomButtons: some View {
HStack(spacing: 20) {
Button(action: {
showLanguageSettings = true
}) {
Text(LocalizedString("login.language", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showUserAgreement = true
}) {
Text(LocalizedString("login.user_agreement", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showPrivacyPolicy = true
}) {
Text(LocalizedString("login.privacy_policy", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
}
}
}
//#Preview {

View File

@@ -6,15 +6,13 @@ struct MainView: View {
var onLogout: (() -> Void)? = nil
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
InternalMainView(store: store)
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
if isLoggedOut {
onLogout?()
}
WithPerceptionTracking {
InternalMainView(store: store)
.onChange(of: store.isLoggedOut) {
if store.isLoggedOut {
onLogout?()
}
}
}
}
}
}
@@ -27,26 +25,24 @@ struct InternalMainView: View {
_path = State(initialValue: store.withState { $0.navigationPath })
}
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
NavigationStack(path: $path) {
GeometryReader { geometry in
contentView(geometry: geometry, viewStore: viewStore)
.navigationDestination(for: MainFeature.Destination.self) { destination in
DestinationView(destination: destination, store: self.store)
WithPerceptionTracking {
NavigationStack(path: $path) {
GeometryReader { geometry in
contentView(geometry: geometry)
.navigationDestination(for: MainFeature.Destination.self) { destination in
DestinationView(destination: destination, store: self.store)
}
.onChange(of: path) {
store.send(.navigationPathChanged(path))
}
.onChange(of: store.navigationPath) {
if path != store.navigationPath {
path = store.navigationPath
}
.onChange(of: path) { newPath in
viewStore.send(.navigationPathChanged(newPath))
}
.onChange(of: viewStore.navigationPath) { newPath in
if path != newPath {
path = newPath
}
}
.onAppear {
viewStore.send(.onAppear)
}
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
@@ -74,7 +70,7 @@ struct InternalMainView: View {
}
}
private func contentView(geometry: GeometryProxy, viewStore: ViewStoreOf<MainFeature>) -> some View {
private func contentView(geometry: GeometryProxy) -> some View {
WithPerceptionTracking {
ZStack {
//
@@ -87,18 +83,27 @@ struct InternalMainView: View {
//
MainContentView(
store: store,
selectedTab: viewStore.selectedTab
selectedTab: store.selectedTab
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
.padding(.bottom, 80) //
// -
VStack {
Spacer()
BottomTabView(selectedTab: viewStore.binding(
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
BottomTabView(selectedTab: Binding(
get: { Tab(rawValue: store.selectedTab.rawValue) ?? .feed },
set: { newTab in
store.send(.selectTab(MainFeature.Tab(rawValue: newTab.rawValue) ?? .feed))
}
))
}
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, 100)
.ignoresSafeArea(.keyboard, edges: .bottom)
// API Loading
APILoadingEffectView()
}
}
}

View File

@@ -5,24 +5,26 @@ struct MeView: View {
let store: StoreOf<MeFeature>
//
@State private var previewItem: PreviewItem? = nil
@State private var previewCurrentIndex: Int = 0
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
VStack {
HStack {
Spacer()
WithViewStore(self.store, observe: { $0 }) { viewStore in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
VStack {
HStack {
Spacer()
Button(action: {
viewStore.send(.settingButtonTapped)
store.send(.settingButtonTapped)
}) {
Image(systemName: "gearshape")
.font(.system(size: 33, weight: .medium))
@@ -31,121 +33,179 @@ struct MeView: View {
.padding(.trailing, 16)
.padding(.top, 8)
}
Spacer()
}
Spacer()
//
VStack(spacing: 16) {
//
userInfoSection()
//
momentsSection()
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.top, 8)
}
VStack(spacing: 16) {
//
WithViewStore(self.store, observe: { $0 }) { viewStore in
if viewStore.isLoadingUserInfo {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(height: 130)
} else if let error = viewStore.userInfoError {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.frame(height: 130)
} else if let userInfo = viewStore.userInfo {
VStack(spacing: 8) {
if let avatarUrl = userInfo.avatar, !avatarUrl.isEmpty {
AsyncImage(url: URL(string: avatarUrl)) { image in
image.resizable().aspectRatio(contentMode: .fill).clipShape(Circle())
} placeholder: {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
}
.frame(width: 90, height: 90)
} else {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
.frame(width: 90, height: 90)
}
Text(userInfo.nick ?? "用户昵称")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
Text("ID: \(userInfo.uid ?? 0)")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.top, 0)
.frame(height: 130)
} else {
Spacer().frame(height: 130)
}
}
//
WithViewStore(self.store, observe: { $0 }) { viewStore in
if viewStore.isLoadingMoments && viewStore.moments.isEmpty {
ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewStore.momentsError {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.yellow)
Text(error)
.foregroundColor(.red)
Button("重试") {
viewStore.send(.onAppear)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewStore.moments.isEmpty {
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("暂无动态")
.foregroundColor(.white.opacity(0.8))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 12) {
ForEach(viewStore.moments.indices, id: \ .self) { index in
let moment = viewStore.moments[index]
OptimizedDynamicCardView(
moment: moment,
allMoments: viewStore.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
}
)
.padding(.horizontal, 12)
}
if viewStore.hasMore {
ProgressView()
.onAppear {
viewStore.send(.loadMore)
}
}
//
Color.clear.frame(height: 120)
}
.padding(.top, 8)
}
}
.refreshable {
viewStore.send(.refresh)
}
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.top, 8)
}
}
.onAppear {
ViewStore(self.store, observe: { $0 }).send(.onAppear)
store.send(.onAppear)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
ImagePreviewPager(images: item.images as [String], currentIndex: $previewCurrentIndex) {
previewItem = nil
}
}
//
.navigationDestination(isPresented: Binding(
get: { store.showDetail },
set: { _ in store.send(.detailDismissed) }
)) {
if let selectedMoment = store.selectedMoment {
let detailStore = Store(
initialState: DetailFeature.State(moment: selectedMoment)
) {
DetailFeature()
}
DetailView(store: detailStore)
.onChange(of: detailStore.shouldDismiss) { shouldDismiss in
if shouldDismiss {
store.send(.detailDismissed)
}
}
}
}
}
}
// MARK: -
@ViewBuilder
private func userInfoSection() -> some View {
WithPerceptionTracking {
VStack(spacing: 16) {
//
AsyncImage(url: URL(string: store.userInfo?.avatar ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Image(systemName: "person.circle.fill")
.resizable()
.aspectRatio(contentMode: .fill)
.foregroundColor(.gray)
}
.frame(width: 80, height: 80)
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 2)
)
//
Text(store.userInfo?.nick ?? "未知用户")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
// ID
Text("ID: \(store.userInfo?.uid ?? 0)")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.horizontal, 32)
}
}
// MARK: -
@ViewBuilder
private func momentsSection() -> some View {
WithPerceptionTracking {
if store.isLoadingUserInfo || store.isLoadingMoments {
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
Text("加载中...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = store.userInfoError ?? store.momentsError {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32))
.foregroundColor(.orange)
Text("加载失败")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text(error)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button("重试") {
store.send(.onAppear)
}
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 24)
.padding(.vertical, 8)
.background(Color.blue.opacity(0.8))
.cornerRadius(8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if store.moments.isEmpty {
VStack(spacing: 12) {
Image(systemName: "doc.text")
.font(.system(size: 32))
.foregroundColor(.white.opacity(0.5))
Text("暂无动态")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 12) {
ForEach(Array(store.moments.enumerated()), id: \.element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: store.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewCurrentIndex = tappedIndex
previewItem = PreviewItem(images: images, index: tappedIndex)
},
onLikeTap: { _, _, _, _ in
//
},
onCardTap: {
store.send(.showDetail(moment))
}
)
.padding(.horizontal, 12)
}
if store.hasMore {
ProgressView()
.onAppear {
store.send(.loadMore)
}
}
//
Color.clear.frame(height: 120)
}
.padding(.top, 8)
}
}
.refreshable {
store.send(.refresh)
}
}
}
}
}

View File

@@ -5,7 +5,7 @@ struct SplashView: View {
let store: StoreOf<SplashFeature>
var body: some View {
WithPerceptionTracking {
ZStack {
Group {
//
if let navigationDestination = store.navigationDestination {
@@ -44,6 +44,9 @@ struct SplashView: View {
.onAppear {
store.send(.onAppear)
}
// API Loading -
APILoadingEffectView()
}
}

View File

@@ -6,48 +6,36 @@ struct TestView: View {
//
Color.purple.ignoresSafeArea()
VStack(spacing: 30) {
//
Text("测试页面")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.white)
VStack(spacing: 20) {
Text(LocalizedString("test.test_page", comment: ""))
.font(.largeTitle)
.fontWeight(.bold)
//
Text("这是一个测试用的页面\n用于验证导航跳转功能")
.font(.system(size: 18))
.foregroundColor(.white.opacity(0.9))
Text(LocalizedString("test.test_description", comment: ""))
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.padding(.horizontal)
//
Button(action: {
debugInfoSync("[LOG] TestView button tapped")
}) {
Text("测试按钮")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.purple)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.white)
.cornerRadius(8)
Button(LocalizedString("test.test_button", comment: "")) {
//
print("测试按钮被点击")
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
Spacer()
}
.padding(.top, 100)
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
debugInfoSync("[LOG] TestView back button tapped")
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .medium))
Text("返回")
.font(.system(size: 16))
.padding()
.navigationTitle(LocalizedString("test.test_page", comment: ""))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(LocalizedString("test.back", comment: "")) {
// dismiss()
}
.foregroundColor(.white)
}
}
}
@@ -58,4 +46,4 @@ struct TestView: View {
NavigationStack {
TestView()
}
}
}

View File

@@ -1,6 +1,7 @@
# Yana 项目问题排查与解决流程文档
## 目录
1. [问题概述](#问题概述)
2. [解决流程](#解决流程)
3. [技术细节](#技术细节)
@@ -13,14 +14,17 @@
## 问题概述
### 初始错误
**错误信息**: `"Could not compute dependency graph: unable to load transferred PIF: The workspace contains multiple references with the same GUID"`
**问题表现**:
- 项目无法启动
- Xcode 无法计算依赖图
- 出现 GUID 冲突错误
### 根本原因分析
1. **混合包管理系统**: 项目同时使用了 Swift Package Manager (SPM) 和 CocoaPods
2. **缓存冲突**: Xcode DerivedData 与 SPM 状态不同步
3. **TCA 结构问题**: 代码中 HomeFeature 缺少必要的状态和 Action 定义
@@ -32,6 +36,7 @@
### 第一阶段GUID 冲突解决
#### 步骤 1: 清理缓存
```bash
# 清理 Xcode DerivedData
rm -rf ~/Library/Developer/Xcode/DerivedData/*
@@ -42,11 +47,13 @@ swift package resolve
```
#### 步骤 2: 重新安装 CocoaPods
```bash
pod install --clean-install
```
#### 步骤 3: 验证项目解析
```bash
xcodebuild -workspace yana.xcworkspace -list
```
@@ -54,13 +61,15 @@ xcodebuild -workspace yana.xcworkspace -list
### 第二阶段TCA 结构修复
#### 问题识别
- `HomeFeature.State` 缺少 `isSettingPresented``settingState` 属性
- `HomeFeature.Action` 缺少 `settingDismissed``setting` actions
- `HomeView.swift` 中的 `store.scope()` 调用语法错误
#### 修复步骤
**1. 修复 HomeFeature.swift**
1. 修复 HomeFeature.swift
```swift
@ObservableState
struct State: Equatable {
@@ -89,7 +98,8 @@ enum Action: Equatable {
}
```
**2. 添加子 Reducer**
2.添加子 Reducer
```swift
var body: some ReducerOf<Self> {
Scope(state: \.settingState, action: \.setting) {
@@ -110,7 +120,8 @@ var body: some ReducerOf<Self> {
}
```
**3. 修复 HomeView.swift**
3.修复 HomeView.swift
```swift
.sheet(isPresented: Binding(
get: { store.isSettingPresented },
@@ -127,10 +138,12 @@ var body: some ReducerOf<Self> {
### 依赖管理配置
**Swift Package Manager (Package.swift)**:
- ComposableArchitecture: 1.20.2+
- 其他依赖根据需要添加
**CocoaPods (Podfile)**:
- Alamofire (网络请求)
- SDWebImage (图像加载)
- CocoaLumberjack (日志)
@@ -147,6 +160,7 @@ Feature
```
### 文件结构
```
yana/
├── Features/ # TCA Feature 定义
@@ -161,6 +175,7 @@ yana/
## 最终解决方案
### 命令执行顺序
```bash
# 1. 清理环境
rm -rf ~/Library/Developer/Xcode/DerivedData/*
@@ -224,33 +239,42 @@ check_project() {
## 常见问题FAQ
### Q1: 再次出现 GUID 冲突怎么办?
**A**: 执行完整清理流程
```bash
rm -rf ~/Library/Developer/Xcode/DerivedData/*
swift package reset && swift package resolve
pod install --clean-install
```
```bash
rm -rf ~/Library/Developer/Xcode/DerivedData/*
swift package reset && swift package resolve
pod install --clean-install
```
### Q2: TCA Reducer 编译错误如何处理?
**A**: 检查以下项目:
- State 属性完整性
- Action 枚举完整性
- Reducer body 中的 case 处理
- 子 Reducer 的 Scope 配置
### Q3: 如何避免混合包管理器问题?
**A**:
**A**:
- 尽量使用单一包管理工具
- 如需混合使用,确保依赖版本兼容
- 定期更新依赖并测试
### Q4: Swift 6 兼容性警告如何处理?
**A**:
**A**:
- 短期:可以忽略,不影响功能
- 长期:逐步迁移到 Swift 6 Sendable 模式
### Q5: 项目构建缓慢怎么办?
**A**:
- 使用 `xcodebuild -quiet` 减少输出
- 开启 Xcode Build System 并行构建
- 定期清理 DerivedData
@@ -271,5 +295,5 @@ pod install --clean-install
---
**文档更新时间**: 2025-07-10
**适用版本**: iOS 16+, Swift 6, TCA 1.20.2+
**维护者**: AI Assistant & 开发团队
**适用版本**: iOS 17+, Swift 6, TCA 1.20.2+
**维护者**: AI Assistant & 开发团队