Compare commits
9 Commits
c072a7e73d
...
a37d7c6eb8
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a37d7c6eb8 | ||
![]() |
bc96cc47ff | ||
![]() |
ac0d622c97 | ||
![]() |
2f3ef22ce5 | ||
![]() |
2cfdf110af | ||
![]() |
79fc03b52a | ||
![]() |
815091a2ff | ||
![]() |
fb09ddb956 | ||
![]() |
343fd9e2df |
@@ -5,6 +5,8 @@ alwaysApply: true
|
|||||||
---
|
---
|
||||||
# CONTEXT
|
# CONTEXT
|
||||||
|
|
||||||
|
This project based on iOS 16.0+ & SwiftUI & 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 wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
|
||||||
|
|
||||||
## OBJECTIVE
|
## OBJECTIVE
|
||||||
|
@@ -9,6 +9,15 @@
|
|||||||
"version" : "1.0.3"
|
"version" : "1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "liquidglass",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/BarredEwe/LiquidGlass.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "d5bf927a08a97c2d94db7ef71f1e15f8532d1005",
|
||||||
|
"version" : "0.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-case-paths",
|
"identity" : "swift-case-paths",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
@@ -16,18 +16,22 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.20.2"),
|
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.20.2"),
|
||||||
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", branch: "main")
|
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", branch: "main"),
|
||||||
|
.package(url: "https://github.com/BarredEwe/LiquidGlass.git", from: "0.7.0")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "yana",
|
name: "yana",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
|
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
|
||||||
]
|
"LiquidGlass"
|
||||||
|
],
|
||||||
|
path: "yana",
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "yanaTests",
|
name: "yanaTests",
|
||||||
dependencies: ["yana"]
|
dependencies: ["yana"],
|
||||||
|
path: "yanaAPITests",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@@ -515,7 +515,7 @@
|
|||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
||||||
SWIFT_STRICT_CONCURRENCY = complete;
|
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
@@ -573,7 +573,7 @@
|
|||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
||||||
SWIFT_STRICT_CONCURRENCY = complete;
|
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
@@ -598,7 +598,7 @@
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
||||||
};
|
};
|
||||||
@@ -621,7 +621,7 @@
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
||||||
};
|
};
|
||||||
|
@@ -33,8 +33,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-collections",
|
"location" : "https://github.com/apple/swift-collections",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
|
"revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
|
||||||
"version" : "1.2.0"
|
"version" : "1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"location" : "https://github.com/pointfreeco/swift-navigation",
|
"location" : "https://github.com/pointfreeco/swift-navigation",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21",
|
"revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21",
|
||||||
"version" : "2.3.1"
|
"version" : "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@@ -24,6 +24,7 @@ enum APIEndpoint: String, CaseIterable {
|
|||||||
case publishFeed = "/dynamic/square/publish" // 发布动态
|
case publishFeed = "/dynamic/square/publish" // 发布动态
|
||||||
case getUserInfo = "/user/get" // 新增:获取用户信息端点
|
case getUserInfo = "/user/get" // 新增:获取用户信息端点
|
||||||
case getMyDynamic = "/dynamic/getMyDynamic"
|
case getMyDynamic = "/dynamic/getMyDynamic"
|
||||||
|
case updateUser = "/user/v2/update" // 新增:用户信息更新端点
|
||||||
|
|
||||||
// Web 页面路径
|
// Web 页面路径
|
||||||
case userAgreement = "/modules/rule/protocol.html"
|
case userAgreement = "/modules/rule/protocol.html"
|
||||||
|
@@ -787,7 +787,7 @@ extension UserInfoManager {
|
|||||||
debugInfoSync("🔄 开始刷新当前用户信息")
|
debugInfoSync("🔄 开始刷新当前用户信息")
|
||||||
debugInfoSync(" 当前UID: \(currentUid)")
|
debugInfoSync(" 当前UID: \(currentUid)")
|
||||||
|
|
||||||
if let userInfo = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) {
|
if let _ = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) {
|
||||||
debugInfoSync("✅ 用户信息刷新成功")
|
debugInfoSync("✅ 用户信息刷新成功")
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -832,7 +832,7 @@ extension UserInfoManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已有用户信息缓存
|
// 检查是否已有用户信息缓存
|
||||||
if let cachedUserInfo = await getUserInfo() {
|
if let _ = await getUserInfo() {
|
||||||
debugInfoSync("📱 APP启动:使用现有用户信息缓存")
|
debugInfoSync("📱 APP启动:使用现有用户信息缓存")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -843,3 +843,32 @@ extension UserInfoManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 用户信息更新
|
||||||
|
struct UpdateUserRequest: APIRequestProtocol {
|
||||||
|
typealias Response = UpdateUserResponse
|
||||||
|
let avatar: String?
|
||||||
|
let nick: String?
|
||||||
|
let uid: Int
|
||||||
|
let ticket: String
|
||||||
|
|
||||||
|
var endpoint: String { APIEndpoint.updateUser.path }
|
||||||
|
var method: HTTPMethod { .POST }
|
||||||
|
// 参数全部通过queryParameters传递
|
||||||
|
var queryParameters: [String: String]? {
|
||||||
|
var params: [String: String] = [
|
||||||
|
"uid": String(uid),
|
||||||
|
"ticket": ticket
|
||||||
|
]
|
||||||
|
if let avatar = avatar { params["avatar"] = avatar }
|
||||||
|
if let nick = nick { params["nick"] = nick }
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
var bodyParameters: [String: Any]? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateUserResponse: Codable, Equatable {
|
||||||
|
let code: Int
|
||||||
|
let message: String
|
||||||
|
let data: UserInfo?
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -222,6 +222,11 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 用户信息更新
|
||||||
|
func updateUser(request: UpdateUserRequest) async throws -> UpdateUserResponse {
|
||||||
|
try await self.request(request)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helper Methods
|
// MARK: - Private Helper Methods
|
||||||
|
|
||||||
/// 构建完整的请求 URL
|
/// 构建完整的请求 URL
|
||||||
|
@@ -581,7 +581,7 @@ extension LoginHelper {
|
|||||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||||
|
|
||||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||||
await debugErrorSync("❌ 邮箱DES加密失败")
|
debugErrorSync("❌ 邮箱DES加密失败")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,84 +3,10 @@ import UIKit
|
|||||||
|
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
||||||
|
|
||||||
// isPerceptionCheckingEnabled = false
|
|
||||||
|
|
||||||
// 执行数据迁移(从 UserDefaults 到 Keychain)
|
|
||||||
DataMigrationManager.performStartupMigration()
|
|
||||||
|
|
||||||
// 预加载用户信息缓存
|
// 预加载用户信息缓存
|
||||||
await UserInfoManager.preloadCache()
|
await UserInfoManager.preloadCache()
|
||||||
|
|
||||||
// 开启网络监控
|
|
||||||
// NetworkManager.shared.networkStatusChanged = { status in
|
|
||||||
// print("🌍 网络状态更新:\(status)")
|
|
||||||
// }
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// 🔍 DES加密已切换到OC版本
|
|
||||||
// print("🔐 使用OC版本的DES加密")
|
|
||||||
// DESEncryptOCTest.runInAppDelegate()
|
|
||||||
|
|
||||||
// 网络诊断 - 使用完整的登录参数测试
|
|
||||||
// let testURL = URL(string: "http://192.168.10.211:8080/oauth/token")!
|
|
||||||
// var request = URLRequest(url: testURL)
|
|
||||||
// request.httpMethod = "POST"
|
|
||||||
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
// request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
||||||
// request.setValue("zh-Hant", forHTTPHeaderField: "Accept-Language")
|
|
||||||
//
|
|
||||||
// // 添加完整的测试参数
|
|
||||||
// let testParameters: [String: Any] = [
|
|
||||||
// "ispType": "65535",
|
|
||||||
// "phone": "3+TbIQYiwIk=",
|
|
||||||
// "netType": 2,
|
|
||||||
// "channel": "molistar_enterprise",
|
|
||||||
// "version": "20.20.61",
|
|
||||||
// "pub_sign": "2E7C50AA17A20B32A0023F20B7ECE108",
|
|
||||||
// "osVersion": "16.4",
|
|
||||||
// "deviceId": "b715b75715e3417c9c70e72bbe502c6c",
|
|
||||||
// "grant_type": "password",
|
|
||||||
// "os": "iOS",
|
|
||||||
// "app": "youmi",
|
|
||||||
// "password": "nTW/lEgupIQ=",
|
|
||||||
// "client_id": "erban-client",
|
|
||||||
// "lang": "zh-Hant-CN",
|
|
||||||
// "client_secret": "uyzjdhds",
|
|
||||||
// "Accept-Language": "zh-Hant",
|
|
||||||
// "model": "iPhone XR",
|
|
||||||
// "appVersion": "1.0.0"
|
|
||||||
// ]
|
|
||||||
//
|
|
||||||
// do {
|
|
||||||
// let jsonData = try JSONSerialization.data(withJSONObject: testParameters, options: .prettyPrinted)
|
|
||||||
// request.httpBody = jsonData
|
|
||||||
//
|
|
||||||
// print("🛠 原生URLSession登录测试开始")
|
|
||||||
// print("📍 测试端点: \(testURL.absoluteString)")
|
|
||||||
// print("📦 请求参数: \(String(data: jsonData, encoding: .utf8) ?? "无法解析")")
|
|
||||||
//
|
|
||||||
// URLSession.shared.dataTask(with: request) { data, response, error in
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
||||||
// let responseString = data != nil ? String(data: data!, encoding: .utf8) ?? "无法解析响应" : "无数据"
|
|
||||||
//
|
|
||||||
// print("""
|
|
||||||
// === 网络诊断结果 ===
|
|
||||||
// 🔗 URL: \(testURL.absoluteString)
|
|
||||||
// 📊 响应状态码: \(statusCode)
|
|
||||||
// ❌ 错误信息: \(error?.localizedDescription ?? "无")
|
|
||||||
// 📦 原始数据: \(data?.count ?? 0) bytes
|
|
||||||
// 📄 响应内容: \(responseString)
|
|
||||||
// ==================
|
|
||||||
// """)
|
|
||||||
// }
|
|
||||||
// }.resume()
|
|
||||||
// } catch {
|
|
||||||
// print("❌ JSON序列化失败: \(error.localizedDescription)")
|
|
||||||
// }
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// NIMConfigurationManager.setupNimSDK()
|
// NIMConfigurationManager.setupNimSDK()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
@@ -14,12 +14,30 @@ struct AppSettingFeature {
|
|||||||
// WebView 导航状态
|
// WebView 导航状态
|
||||||
var showUserAgreement: Bool = false
|
var showUserAgreement: Bool = false
|
||||||
var showPrivacyPolicy: Bool = false
|
var showPrivacyPolicy: Bool = false
|
||||||
|
|
||||||
|
// 头像/昵称修改相关
|
||||||
|
var isUploadingAvatar: Bool = false
|
||||||
|
var avatarUploadError: String? = nil
|
||||||
|
var isEditingNickname: Bool = false
|
||||||
|
var nicknameInput: String = ""
|
||||||
|
var isUpdatingUser: Bool = false
|
||||||
|
var updateUserError: String? = nil
|
||||||
|
|
||||||
|
// 新增:带userInfo、avatarURL、nickname的init
|
||||||
|
init(nickname: String = "", avatarURL: String? = nil, userInfo: UserInfo? = nil) {
|
||||||
|
self.nickname = nickname
|
||||||
|
self.avatarURL = avatarURL
|
||||||
|
self.userInfo = userInfo
|
||||||
|
}
|
||||||
|
// 新增:TCA驱动图片选择弹窗
|
||||||
|
var showImagePicker: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Action: Equatable {
|
enum Action: Equatable {
|
||||||
case onAppear
|
case onAppear
|
||||||
case editNicknameTapped
|
case editNicknameTapped
|
||||||
case logoutTapped
|
case logoutTapped
|
||||||
|
case dismissTapped
|
||||||
|
|
||||||
// 用户信息相关
|
// 用户信息相关
|
||||||
case loadUserInfo
|
case loadUserInfo
|
||||||
@@ -35,89 +53,191 @@ struct AppSettingFeature {
|
|||||||
// WebView 关闭
|
// WebView 关闭
|
||||||
case userAgreementDismissed
|
case userAgreementDismissed
|
||||||
case privacyPolicyDismissed
|
case privacyPolicyDismissed
|
||||||
|
|
||||||
|
// 头像/昵称修改
|
||||||
|
case avatarTapped
|
||||||
|
case avatarSelected(Data)
|
||||||
|
case avatarUploadResult(Result<String, APIError>)
|
||||||
|
case nicknameEditConfirmed(String)
|
||||||
|
case updateUser(Result<UpdateUserResponse, APIError>)
|
||||||
|
case nicknameInputChanged(String)
|
||||||
|
case nicknameEditAlert(Bool)
|
||||||
|
case testPushTapped
|
||||||
|
// 新增:TCA驱动图片选择弹窗
|
||||||
|
case setShowImagePicker(Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dependency(\.apiService) var apiService
|
@Dependency(\.apiService) var apiService
|
||||||
|
|
||||||
var body: some ReducerOf<Self> {
|
func reduce(into state: inout State, action: Action) -> Effect<Action> {
|
||||||
Reduce { state, action in
|
switch action {
|
||||||
switch action {
|
case .onAppear:
|
||||||
case .onAppear:
|
return .send(.loadUserInfo)
|
||||||
return .send(.loadUserInfo)
|
|
||||||
|
case .editNicknameTapped:
|
||||||
|
// 预留编辑昵称逻辑
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .logoutTapped:
|
||||||
|
// 清理所有认证信息,并向上层发送登出事件
|
||||||
|
return .run { send in
|
||||||
|
await UserInfoManager.clearAllAuthenticationData()
|
||||||
|
// 向上层Feature传递登出事件(需在MainFeature处理)
|
||||||
|
// 这里直接返回.none,由MainFeature监听AppSettingFeature.Action.logoutTapped后处理
|
||||||
|
}
|
||||||
|
|
||||||
|
case .dismissTapped:
|
||||||
|
// 返回上一页,由 MainFeature 处理 navigationPath 的 pop 操作
|
||||||
|
return .none
|
||||||
|
|
||||||
case .editNicknameTapped:
|
case .loadUserInfo:
|
||||||
// 预留编辑昵称逻辑
|
state.isLoadingUserInfo = true
|
||||||
return .none
|
state.userInfoError = nil
|
||||||
|
return .run { send in
|
||||||
case .logoutTapped:
|
// do {
|
||||||
// 清理所有认证信息,并向上层发送登出事件
|
if let userInfo = await UserInfoManager.getUserInfo() {
|
||||||
return .run { send in
|
await send(.userInfoResponse(.success(userInfo)))
|
||||||
await UserInfoManager.clearAllAuthenticationData()
|
} else {
|
||||||
// 向上层Feature传递登出事件(需在MainFeature处理)
|
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
|
||||||
// 这里直接返回.none,由MainFeature监听AppSettingFeature.Action.logoutTapped后处理
|
}
|
||||||
|
// } catch {
|
||||||
|
// let apiError: APIError
|
||||||
|
// if let error = error as? APIError {
|
||||||
|
// apiError = error
|
||||||
|
// } else {
|
||||||
|
// apiError = APIError.custom(error.localizedDescription)
|
||||||
|
// }
|
||||||
|
// await send(.userInfoResponse(.failure(apiError)))
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
case let .userInfoResponse(.success(userInfo)):
|
||||||
|
state.userInfo = userInfo
|
||||||
|
state.nickname = userInfo.nick ?? ""
|
||||||
|
state.avatarURL = userInfo.avatar // 确保同步
|
||||||
|
state.isLoadingUserInfo = false
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case let .userInfoResponse(.failure(error)):
|
||||||
|
state.userInfoError = error.localizedDescription
|
||||||
|
state.isLoadingUserInfo = false
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .personalInfoPermissionsTapped:
|
||||||
|
state.showPrivacyPolicy = true
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .helpTapped:
|
||||||
|
state.showUserAgreement = true
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .clearCacheTapped:
|
||||||
|
// 预留清除缓存逻辑
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .checkUpdatesTapped:
|
||||||
|
// 预留检查更新逻辑
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .aboutUsTapped:
|
||||||
|
// 预留关于我们逻辑
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .userAgreementDismissed:
|
||||||
|
state.showUserAgreement = false
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .privacyPolicyDismissed:
|
||||||
|
state.showPrivacyPolicy = false
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .avatarTapped:
|
||||||
|
// 触发头像选择器
|
||||||
|
return .none
|
||||||
|
case let .avatarSelected(imageData):
|
||||||
|
state.isUploadingAvatar = true
|
||||||
|
state.avatarUploadError = nil
|
||||||
|
return .run { [avatarData = imageData] send in
|
||||||
|
guard let uiImage = UIImage(data: avatarData) else {
|
||||||
|
await send(.avatarUploadResult(.failure(APIError.custom("图片格式错误"))))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
// 上传图片到腾讯云
|
||||||
case .loadUserInfo:
|
if let url = await COSManager.shared.uploadUIImage(uiImage, apiService: apiService) {
|
||||||
state.isLoadingUserInfo = true
|
await send(.avatarUploadResult(.success(url)))
|
||||||
state.userInfoError = nil
|
} else {
|
||||||
|
await send(.avatarUploadResult(.failure(APIError.custom("头像上传失败"))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .avatarUploadResult(.success(url)):
|
||||||
|
state.isUploadingAvatar = false
|
||||||
|
// 调用 updateUser API,仅传 avatar
|
||||||
|
state.isUpdatingUser = true
|
||||||
|
state.updateUserError = nil
|
||||||
|
guard let userInfo = state.userInfo else { return .none }
|
||||||
|
// 头像上传后,先临时更新本地avatarURL,提升UI响应
|
||||||
|
state.avatarURL = url
|
||||||
|
return .run { send in
|
||||||
|
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
|
||||||
|
let req = UpdateUserRequest(avatar: url, nick: nil, uid: userInfo.uid ?? 0, ticket: ticket)
|
||||||
|
do {
|
||||||
|
let resp: UpdateUserResponse = try await apiService.request(req)
|
||||||
|
await send(.updateUser(.success(resp)))
|
||||||
|
} catch {
|
||||||
|
let apiError = error as? APIError ?? APIError.custom(error.localizedDescription)
|
||||||
|
await send(.updateUser(.failure(apiError)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .avatarUploadResult(.failure(error)):
|
||||||
|
state.isUploadingAvatar = false
|
||||||
|
state.avatarUploadError = error.localizedDescription
|
||||||
|
return .none
|
||||||
|
case .nicknameEditAlert(let show):
|
||||||
|
state.isEditingNickname = show
|
||||||
|
state.nicknameInput = state.nickname
|
||||||
|
return .none
|
||||||
|
case .nicknameInputChanged(let text):
|
||||||
|
state.nicknameInput = String(text.prefix(15))
|
||||||
|
return .none
|
||||||
|
case .nicknameEditConfirmed(let newNick):
|
||||||
|
guard let userInfo = state.userInfo else { return .none }
|
||||||
|
state.isUpdatingUser = true
|
||||||
|
state.updateUserError = nil
|
||||||
|
return .run { send in
|
||||||
|
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
|
||||||
|
let req = UpdateUserRequest(avatar: nil, nick: newNick, uid: userInfo.uid ?? 0, ticket: ticket)
|
||||||
|
do {
|
||||||
|
let resp: UpdateUserResponse = try await apiService.request(req)
|
||||||
|
await send(.updateUser(.success(resp)))
|
||||||
|
} catch {
|
||||||
|
let apiError = error as? APIError ?? APIError.custom(error.localizedDescription)
|
||||||
|
await send(.updateUser(.failure(apiError)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .updateUser(.success(_)):
|
||||||
|
state.isUpdatingUser = false
|
||||||
|
// 不直接用 resp.data,触发拉取完整 userinfo,延迟1秒
|
||||||
|
if let uid = state.userInfo?.uid {
|
||||||
return .run { send in
|
return .run { send in
|
||||||
do {
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
if let userInfo = await UserInfoManager.getUserInfo() {
|
if let newUser = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
|
||||||
await send(.userInfoResponse(.success(userInfo)))
|
await send(.userInfoResponse(.success(newUser)))
|
||||||
} else {
|
} else {
|
||||||
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
|
await send(.userInfoResponse(.failure(APIError.custom("获取最新用户信息失败"))))
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
let apiError: APIError
|
|
||||||
if let error = error as? APIError {
|
|
||||||
apiError = error
|
|
||||||
} else {
|
|
||||||
apiError = APIError.custom(error.localizedDescription)
|
|
||||||
}
|
|
||||||
await send(.userInfoResponse(.failure(apiError)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case let .userInfoResponse(.success(userInfo)):
|
|
||||||
state.userInfo = userInfo
|
|
||||||
state.nickname = userInfo.nick ?? ""
|
|
||||||
state.avatarURL = userInfo.avatar
|
|
||||||
state.isLoadingUserInfo = false
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case let .userInfoResponse(.failure(error)):
|
|
||||||
state.userInfoError = error.localizedDescription
|
|
||||||
state.isLoadingUserInfo = false
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .personalInfoPermissionsTapped:
|
|
||||||
state.showPrivacyPolicy = true
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .helpTapped:
|
|
||||||
state.showUserAgreement = true
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .clearCacheTapped:
|
|
||||||
// 预留清除缓存逻辑
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .checkUpdatesTapped:
|
|
||||||
// 预留检查更新逻辑
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .aboutUsTapped:
|
|
||||||
// 预留关于我们逻辑
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .userAgreementDismissed:
|
|
||||||
state.showUserAgreement = false
|
|
||||||
return .none
|
|
||||||
|
|
||||||
case .privacyPolicyDismissed:
|
|
||||||
state.showPrivacyPolicy = false
|
|
||||||
return .none
|
|
||||||
}
|
}
|
||||||
|
state.isEditingNickname = false
|
||||||
|
return .none
|
||||||
|
case let .updateUser(.failure(error)):
|
||||||
|
state.isUpdatingUser = false
|
||||||
|
state.updateUserError = error.localizedDescription
|
||||||
|
return .none
|
||||||
|
case .testPushTapped:
|
||||||
|
return .none
|
||||||
|
case .setShowImagePicker(let show):
|
||||||
|
state.showImagePicker = show
|
||||||
|
return .none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -27,6 +27,7 @@ struct FeedListFeature {
|
|||||||
case loadMoreResponse(TaskResult<MomentsLatestResponse>)
|
case loadMoreResponse(TaskResult<MomentsLatestResponse>)
|
||||||
case editFeedButtonTapped // 新增:点击 add 按钮
|
case editFeedButtonTapped // 新增:点击 add 按钮
|
||||||
case editFeedDismissed // 新增:关闭编辑页
|
case editFeedDismissed // 新增:关闭编辑页
|
||||||
|
case testButtonTapped // 新增:点击测试按钮
|
||||||
// 新增:动态内容相关
|
// 新增:动态内容相关
|
||||||
case fetchFeeds
|
case fetchFeeds
|
||||||
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
|
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
|
||||||
@@ -125,6 +126,9 @@ struct FeedListFeature {
|
|||||||
case .editFeedDismissed:
|
case .editFeedDismissed:
|
||||||
state.isEditFeedPresented = false
|
state.isEditFeedPresented = false
|
||||||
return .none
|
return .none
|
||||||
|
case .testButtonTapped:
|
||||||
|
debugInfoSync("[LOG] FeedListFeature testButtonTapped")
|
||||||
|
return .none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,11 +2,13 @@ import Foundation
|
|||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
import CasePaths
|
import CasePaths
|
||||||
|
|
||||||
struct MainFeature: Reducer {
|
@Reducer
|
||||||
|
struct MainFeature {
|
||||||
enum Tab: Int, Equatable, CaseIterable {
|
enum Tab: Int, Equatable, CaseIterable {
|
||||||
case feed, other
|
case feed, other
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObservableState
|
||||||
struct State: Equatable {
|
struct State: Equatable {
|
||||||
var selectedTab: Tab = .feed
|
var selectedTab: Tab = .feed
|
||||||
var feedList: FeedListFeature.State = .init()
|
var feedList: FeedListFeature.State = .init()
|
||||||
@@ -20,9 +22,9 @@ struct MainFeature: Reducer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 新增:导航目标
|
// 新增:导航目标
|
||||||
enum Destination: Hashable, Equatable {
|
enum Destination: Hashable, Codable, CaseIterable {
|
||||||
case test
|
|
||||||
case appSetting
|
case appSetting
|
||||||
|
case testView
|
||||||
}
|
}
|
||||||
|
|
||||||
@CasePathable
|
@CasePathable
|
||||||
@@ -34,7 +36,6 @@ struct MainFeature: Reducer {
|
|||||||
case accountModelLoaded(AccountModel?)
|
case accountModelLoaded(AccountModel?)
|
||||||
// 新增:导航相关
|
// 新增:导航相关
|
||||||
case navigationPathChanged([Destination])
|
case navigationPathChanged([Destination])
|
||||||
case testButtonTapped
|
|
||||||
case appSettingButtonTapped
|
case appSettingButtonTapped
|
||||||
case appSettingAction(AppSettingFeature.Action)
|
case appSettingAction(AppSettingFeature.Action)
|
||||||
// 新增:登出
|
// 新增:登出
|
||||||
@@ -42,10 +43,10 @@ struct MainFeature: Reducer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some ReducerOf<Self> {
|
var body: some ReducerOf<Self> {
|
||||||
Scope(state: \.feedList, action: \.feedList) {
|
Scope(state: \ .feedList, action: \ .feedList) {
|
||||||
FeedListFeature()
|
FeedListFeature()
|
||||||
}
|
}
|
||||||
Scope(state: \.me, action: \.me) {
|
Scope(state: \ .me, action: \ .me) {
|
||||||
MeFeature()
|
MeFeature()
|
||||||
}
|
}
|
||||||
Reduce { state, action in
|
Reduce { state, action in
|
||||||
@@ -66,36 +67,48 @@ struct MainFeature: Reducer {
|
|||||||
return .send(.me(.onAppear))
|
return .send(.me(.onAppear))
|
||||||
}
|
}
|
||||||
return .none
|
return .none
|
||||||
|
case .feedList(.testButtonTapped):
|
||||||
|
state.navigationPath.append(.testView)
|
||||||
|
return .none
|
||||||
case .feedList:
|
case .feedList:
|
||||||
return .none
|
return .none
|
||||||
case let .accountModelLoaded(accountModel):
|
case let .accountModelLoaded(accountModel):
|
||||||
state.accountModel = accountModel
|
state.accountModel = accountModel
|
||||||
return .none
|
return .none
|
||||||
case .me(.settingButtonTapped):
|
case .me(.settingButtonTapped):
|
||||||
// 触发 push 到设置页
|
// 触发 push 到设置页,带入当前用户信息
|
||||||
state.appSettingState = AppSettingFeature.State()
|
let userInfo = state.me.userInfo
|
||||||
|
let avatarURL = userInfo?.avatar
|
||||||
|
let nickname = userInfo?.nick ?? ""
|
||||||
|
state.appSettingState = AppSettingFeature.State(nickname: nickname, avatarURL: avatarURL, userInfo: userInfo)
|
||||||
state.navigationPath.append(.appSetting)
|
state.navigationPath.append(.appSetting)
|
||||||
return .none
|
return .none
|
||||||
case .me:
|
case .me:
|
||||||
return .none
|
return .none
|
||||||
case .navigationPathChanged(let newPath):
|
case .navigationPathChanged(let newPath):
|
||||||
// pop 回来时清空 settingState
|
// pop 回来时清空 settingState
|
||||||
if !newPath.contains(.appSetting) {
|
|
||||||
state.appSettingState = nil
|
|
||||||
}
|
|
||||||
state.navigationPath = newPath
|
state.navigationPath = newPath
|
||||||
return .none
|
return .none
|
||||||
case .testButtonTapped:
|
|
||||||
state.navigationPath.append(.test)
|
|
||||||
return .none
|
|
||||||
case .appSettingButtonTapped:
|
case .appSettingButtonTapped:
|
||||||
state.appSettingState = AppSettingFeature.State()
|
let userInfo = state.me.userInfo
|
||||||
|
let avatarURL = userInfo?.avatar
|
||||||
|
let nickname = userInfo?.nick ?? ""
|
||||||
|
state.appSettingState = AppSettingFeature.State(nickname: nickname, avatarURL: avatarURL, userInfo: userInfo)
|
||||||
state.navigationPath.append(.appSetting)
|
state.navigationPath.append(.appSetting)
|
||||||
return .none
|
return .none
|
||||||
case .appSettingAction(.logoutTapped):
|
case .appSettingAction(.logoutTapped):
|
||||||
// 监听到登出,设置登出标志
|
// 监听到登出,设置登出标志
|
||||||
state.isLoggedOut = true
|
state.isLoggedOut = true
|
||||||
return .none
|
return .none
|
||||||
|
case .appSettingAction(.dismissTapped):
|
||||||
|
// 监听到设置页的返回按钮,pop 导航栈
|
||||||
|
if !state.navigationPath.isEmpty {
|
||||||
|
state.navigationPath.removeLast()
|
||||||
|
}
|
||||||
|
return .none
|
||||||
|
case .appSettingAction(.updateUser(.success)):
|
||||||
|
// 设置页用户信息变更成功,刷新Me页数据
|
||||||
|
return .send(.me(.refresh))
|
||||||
case .appSettingAction:
|
case .appSettingAction:
|
||||||
return .none
|
return .none
|
||||||
case .logout:
|
case .logout:
|
||||||
@@ -104,8 +117,8 @@ struct MainFeature: Reducer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 设置页作用域
|
// 设置页作用域
|
||||||
.ifLet(\ .appSettingState, action: \.appSettingAction) {
|
.ifLet(\ .appSettingState, action: \ .appSettingAction) {
|
||||||
AppSettingFeature()
|
AppSettingFeature()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -87,15 +87,15 @@ struct MeFeature {
|
|||||||
|
|
||||||
private func fetchUserInfo(uid: Int) -> Effect<Action> {
|
private func fetchUserInfo(uid: Int) -> Effect<Action> {
|
||||||
.run { send in
|
.run { send in
|
||||||
do {
|
// do {
|
||||||
if let userInfo = try await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
|
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
|
||||||
await send(.userInfoResponse(.success(userInfo)))
|
await send(.userInfoResponse(.success(userInfo)))
|
||||||
} else {
|
} else {
|
||||||
await send(.userInfoResponse(.failure(.noData)))
|
await send(.userInfoResponse(.failure(.noData)))
|
||||||
}
|
}
|
||||||
} catch {
|
// } catch {
|
||||||
await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
|
// await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,4 +110,4 @@ struct MeFeature {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,6 +9,8 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSWiFiUsageDescription</key>
|
<key>NSWiFiUsageDescription</key>
|
||||||
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
|
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>需要使用相机拍照上传图片</string>
|
||||||
<key>UIAppFonts</key>
|
<key>UIAppFonts</key>
|
||||||
<array>
|
<array>
|
||||||
<string>Bayon-Regular.ttf</string>
|
<string>Bayon-Regular.ttf</string>
|
||||||
|
@@ -1,357 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// 数据迁移管理器
|
|
||||||
///
|
|
||||||
/// 负责将旧版本的 UserDefaults 存储数据迁移到新的 Keychain 存储方案。
|
|
||||||
/// 确保用户升级应用后无需重新登录。
|
|
||||||
///
|
|
||||||
/// 迁移策略:
|
|
||||||
/// 1. 检测旧数据是否存在
|
|
||||||
/// 2. 迁移到 Keychain
|
|
||||||
/// 3. 验证迁移结果
|
|
||||||
/// 4. 清理旧数据
|
|
||||||
@MainActor
|
|
||||||
final class DataMigrationManager {
|
|
||||||
|
|
||||||
// MARK: - 单例
|
|
||||||
static let shared = DataMigrationManager()
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - 迁移状态
|
|
||||||
private let migrationCompleteKey = "keychain_migration_completed_v1"
|
|
||||||
|
|
||||||
// MARK: - 旧版本存储键
|
|
||||||
private enum LegacyStorageKeys {
|
|
||||||
static let userId = "user_id"
|
|
||||||
static let accessToken = "access_token"
|
|
||||||
static let userInfo = "user_info"
|
|
||||||
static let accountModel = "account_model"
|
|
||||||
static let appLanguage = "AppLanguage"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 迁移结果
|
|
||||||
enum MigrationResult {
|
|
||||||
case completed // 迁移完成
|
|
||||||
case alreadyMigrated // 已经迁移过
|
|
||||||
case noDataToMigrate // 没有需要迁移的数据
|
|
||||||
case failed(Error) // 迁移失败
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .completed:
|
|
||||||
return "数据迁移完成"
|
|
||||||
case .alreadyMigrated:
|
|
||||||
return "数据已经迁移过"
|
|
||||||
case .noDataToMigrate:
|
|
||||||
return "没有需要迁移的数据"
|
|
||||||
case .failed(let error):
|
|
||||||
return "迁移失败: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 公共方法
|
|
||||||
|
|
||||||
/// 执行数据迁移
|
|
||||||
/// - Returns: 迁移结果
|
|
||||||
func performMigration() -> MigrationResult {
|
|
||||||
debugInfoSync("🔄 开始检查数据迁移...")
|
|
||||||
|
|
||||||
// 检查是否已经迁移过
|
|
||||||
if isMigrationCompleted() {
|
|
||||||
debugInfoSync("✅ 数据已经迁移过,跳过迁移")
|
|
||||||
return .alreadyMigrated
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有需要迁移的数据
|
|
||||||
let legacyData = collectLegacyData()
|
|
||||||
if legacyData.isEmpty {
|
|
||||||
debugInfoSync("ℹ️ 没有发现需要迁移的数据")
|
|
||||||
markMigrationCompleted()
|
|
||||||
return .noDataToMigrate
|
|
||||||
}
|
|
||||||
|
|
||||||
debugInfoSync("📦 发现需要迁移的数据: \(legacyData.keys.joined(separator: ", "))")
|
|
||||||
|
|
||||||
do {
|
|
||||||
// 执行迁移
|
|
||||||
try migrateToKeychain(legacyData)
|
|
||||||
|
|
||||||
// 验证迁移结果
|
|
||||||
try verifyMigration(legacyData)
|
|
||||||
|
|
||||||
// 清理旧数据
|
|
||||||
cleanupLegacyData(legacyData.keys)
|
|
||||||
|
|
||||||
// 标记迁移完成
|
|
||||||
markMigrationCompleted()
|
|
||||||
|
|
||||||
debugInfoSync("✅ 数据迁移完成")
|
|
||||||
return .completed
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
debugErrorSync("❌ 数据迁移失败: \(error)")
|
|
||||||
return .failed(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 强制重新迁移(用于测试或修复)
|
|
||||||
func forceMigration() -> MigrationResult {
|
|
||||||
resetMigrationStatus()
|
|
||||||
return performMigration()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 私有方法
|
|
||||||
|
|
||||||
/// 检查迁移是否已完成
|
|
||||||
private func isMigrationCompleted() -> Bool {
|
|
||||||
return UserDefaults.standard.bool(forKey: migrationCompleteKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 标记迁移完成
|
|
||||||
private func markMigrationCompleted() {
|
|
||||||
UserDefaults.standard.set(true, forKey: migrationCompleteKey)
|
|
||||||
UserDefaults.standard.synchronize()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置迁移状态
|
|
||||||
private func resetMigrationStatus() {
|
|
||||||
UserDefaults.standard.removeObject(forKey: migrationCompleteKey)
|
|
||||||
UserDefaults.standard.synchronize()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 收集旧版本数据
|
|
||||||
private func collectLegacyData() -> [String: Any] {
|
|
||||||
let userDefaults = UserDefaults.standard
|
|
||||||
var legacyData: [String: Any] = [:]
|
|
||||||
|
|
||||||
// 检查各种旧数据
|
|
||||||
if let userId = userDefaults.string(forKey: LegacyStorageKeys.userId) {
|
|
||||||
legacyData[LegacyStorageKeys.userId] = userId
|
|
||||||
}
|
|
||||||
|
|
||||||
if let accessToken = userDefaults.string(forKey: LegacyStorageKeys.accessToken) {
|
|
||||||
legacyData[LegacyStorageKeys.accessToken] = accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
if let userInfoData = userDefaults.data(forKey: LegacyStorageKeys.userInfo) {
|
|
||||||
legacyData[LegacyStorageKeys.userInfo] = userInfoData
|
|
||||||
}
|
|
||||||
|
|
||||||
if let accountModelData = userDefaults.data(forKey: LegacyStorageKeys.accountModel) {
|
|
||||||
legacyData[LegacyStorageKeys.accountModel] = accountModelData
|
|
||||||
}
|
|
||||||
|
|
||||||
if let appLanguage = userDefaults.string(forKey: LegacyStorageKeys.appLanguage) {
|
|
||||||
legacyData[LegacyStorageKeys.appLanguage] = appLanguage
|
|
||||||
}
|
|
||||||
|
|
||||||
return legacyData
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移数据到 Keychain
|
|
||||||
private func migrateToKeychain(_ legacyData: [String: Any]) throws {
|
|
||||||
let keychain = KeychainManager.shared
|
|
||||||
|
|
||||||
// 迁移 AccountModel(优先级最高,包含完整认证信息)
|
|
||||||
if let accountModelData = legacyData[LegacyStorageKeys.accountModel] as? Data {
|
|
||||||
do {
|
|
||||||
let accountModel = try JSONDecoder().decode(AccountModel.self, from: accountModelData)
|
|
||||||
try keychain.store(accountModel, forKey: "account_model")
|
|
||||||
debugInfoSync("✅ AccountModel 迁移成功")
|
|
||||||
} catch {
|
|
||||||
debugErrorSync("❌ AccountModel 迁移失败: \(error)")
|
|
||||||
// 如果 AccountModel 迁移失败,尝试从独立字段重建
|
|
||||||
try migrateAccountModelFromIndependentFields(legacyData)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果没有 AccountModel,从独立字段构建
|
|
||||||
try migrateAccountModelFromIndependentFields(legacyData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 迁移 UserInfo
|
|
||||||
if let userInfoData = legacyData[LegacyStorageKeys.userInfo] as? Data {
|
|
||||||
do {
|
|
||||||
let userInfo = try JSONDecoder().decode(UserInfo.self, from: userInfoData)
|
|
||||||
try keychain.store(userInfo, forKey: "user_info")
|
|
||||||
debugInfoSync("✅ UserInfo 迁移成功")
|
|
||||||
} catch {
|
|
||||||
debugErrorSync("❌ UserInfo 迁移失败: \(error)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 迁移语言设置
|
|
||||||
if let appLanguage = legacyData[LegacyStorageKeys.appLanguage] as? String {
|
|
||||||
try keychain.storeString(appLanguage, forKey: "AppLanguage")
|
|
||||||
debugInfoSync("✅ 语言设置迁移成功")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从独立字段重建 AccountModel
|
|
||||||
private func migrateAccountModelFromIndependentFields(_ legacyData: [String: Any]) throws {
|
|
||||||
guard let userId = legacyData[LegacyStorageKeys.userId] as? String,
|
|
||||||
let accessToken = legacyData[LegacyStorageKeys.accessToken] as? String else {
|
|
||||||
debugInfoSync("ℹ️ 没有足够的独立字段来重建 AccountModel")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let accountModel = AccountModel(
|
|
||||||
uid: userId,
|
|
||||||
jti: nil,
|
|
||||||
tokenType: "bearer",
|
|
||||||
refreshToken: nil,
|
|
||||||
netEaseToken: nil,
|
|
||||||
accessToken: accessToken,
|
|
||||||
expiresIn: nil,
|
|
||||||
scope: nil,
|
|
||||||
ticket: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
try KeychainManager.shared.store(accountModel, forKey: "account_model")
|
|
||||||
debugInfoSync("✅ 从独立字段重建 AccountModel 成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 验证迁移结果
|
|
||||||
private func verifyMigration(_ legacyData: [String: Any]) throws {
|
|
||||||
let keychain = KeychainManager.shared
|
|
||||||
|
|
||||||
// 验证 AccountModel
|
|
||||||
if legacyData[LegacyStorageKeys.accountModel] != nil ||
|
|
||||||
(legacyData[LegacyStorageKeys.userId] != nil && legacyData[LegacyStorageKeys.accessToken] != nil) {
|
|
||||||
let accountModel: AccountModel? = try keychain.retrieve(AccountModel.self, forKey: "account_model")
|
|
||||||
guard accountModel != nil else {
|
|
||||||
throw MigrationError.verificationFailed("AccountModel 验证失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 UserInfo
|
|
||||||
if legacyData[LegacyStorageKeys.userInfo] != nil {
|
|
||||||
let userInfo: UserInfo? = try keychain.retrieve(UserInfo.self, forKey: "user_info")
|
|
||||||
guard userInfo != nil else {
|
|
||||||
throw MigrationError.verificationFailed("UserInfo 验证失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证语言设置
|
|
||||||
if legacyData[LegacyStorageKeys.appLanguage] != nil {
|
|
||||||
let appLanguage = try keychain.retrieveString(forKey: "AppLanguage")
|
|
||||||
guard appLanguage != nil else {
|
|
||||||
throw MigrationError.verificationFailed("语言设置验证失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debugInfoSync("✅ 迁移数据验证成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清理旧数据
|
|
||||||
private func cleanupLegacyData(_ keys: Dictionary<String, Any>.Keys) {
|
|
||||||
let userDefaults = UserDefaults.standard
|
|
||||||
|
|
||||||
for key in keys {
|
|
||||||
userDefaults.removeObject(forKey: key)
|
|
||||||
debugInfoSync("🗑️ 清理旧数据: \(key)")
|
|
||||||
}
|
|
||||||
|
|
||||||
userDefaults.synchronize()
|
|
||||||
debugInfoSync("✅ 旧数据清理完成")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 迁移错误
|
|
||||||
|
|
||||||
enum MigrationError: Error, LocalizedError {
|
|
||||||
case verificationFailed(String)
|
|
||||||
case dataCorrupted(String)
|
|
||||||
case keychainError(Error)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .verificationFailed(let message):
|
|
||||||
return "验证失败: \(message)"
|
|
||||||
case .dataCorrupted(let message):
|
|
||||||
return "数据损坏: \(message)"
|
|
||||||
case .keychainError(let error):
|
|
||||||
return "Keychain 错误: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 应用启动时的迁移支持
|
|
||||||
|
|
||||||
extension DataMigrationManager {
|
|
||||||
|
|
||||||
/// 在应用启动时执行迁移
|
|
||||||
/// 这个方法应该在 AppDelegate 或 App 的初始化阶段调用
|
|
||||||
static func performStartupMigration() {
|
|
||||||
let migrationResult = DataMigrationManager.shared.performMigration()
|
|
||||||
|
|
||||||
switch migrationResult {
|
|
||||||
case .completed:
|
|
||||||
debugInfoSync("🎉 应用启动时数据迁移完成")
|
|
||||||
case .alreadyMigrated:
|
|
||||||
break // 静默处理
|
|
||||||
case .noDataToMigrate:
|
|
||||||
break // 静默处理
|
|
||||||
case .failed(let error):
|
|
||||||
debugErrorSync("⚠️ 应用启动时数据迁移失败: \(error)")
|
|
||||||
// 这里可以添加错误上报或降级策略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 调试支持
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension DataMigrationManager {
|
|
||||||
|
|
||||||
/// 调试:打印旧数据信息
|
|
||||||
func debugPrintLegacyData() {
|
|
||||||
let legacyData = collectLegacyData()
|
|
||||||
debugInfoSync("🔍 旧版本数据:")
|
|
||||||
for (key, value) in legacyData {
|
|
||||||
debugInfoSync(" - \(key): \(type(of: value))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 调试:模拟创建旧数据(用于测试)
|
|
||||||
func debugCreateLegacyData() {
|
|
||||||
let userDefaults = UserDefaults.standard
|
|
||||||
|
|
||||||
userDefaults.set("test_user_123", forKey: LegacyStorageKeys.userId)
|
|
||||||
userDefaults.set("test_access_token", forKey: LegacyStorageKeys.accessToken)
|
|
||||||
userDefaults.set("zh-Hans", forKey: LegacyStorageKeys.appLanguage)
|
|
||||||
userDefaults.synchronize()
|
|
||||||
|
|
||||||
debugInfoSync("🧪 已创建测试用的旧版本数据")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 调试:清除所有迁移相关数据
|
|
||||||
func debugClearAllData() {
|
|
||||||
// 清除 Keychain 数据
|
|
||||||
do {
|
|
||||||
try KeychainManager.shared.clearAll()
|
|
||||||
} catch {
|
|
||||||
debugErrorSync("❌ 清除 Keychain 数据失败: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除 UserDefaults 数据
|
|
||||||
let userDefaults = UserDefaults.standard
|
|
||||||
let allKeys = [
|
|
||||||
LegacyStorageKeys.userId,
|
|
||||||
LegacyStorageKeys.accessToken,
|
|
||||||
LegacyStorageKeys.userInfo,
|
|
||||||
LegacyStorageKeys.accountModel,
|
|
||||||
LegacyStorageKeys.appLanguage,
|
|
||||||
migrationCompleteKey
|
|
||||||
]
|
|
||||||
|
|
||||||
for key in allKeys {
|
|
||||||
userDefaults.removeObject(forKey: key)
|
|
||||||
}
|
|
||||||
userDefaults.synchronize()
|
|
||||||
|
|
||||||
debugInfoSync("🧪 已清除所有迁移相关数据")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
@@ -27,7 +27,7 @@ struct AppRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//
|
||||||
#Preview {
|
//#Preview {
|
||||||
AppRootView()
|
// AppRootView()
|
||||||
}
|
//}
|
||||||
|
@@ -1,74 +1,209 @@
|
|||||||
|
//
|
||||||
|
// AppSettingView.swift
|
||||||
|
// yana
|
||||||
|
//
|
||||||
|
// Created by Edwin on 2024/11/20.
|
||||||
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
struct AppSettingView: View {
|
struct AppSettingView: View {
|
||||||
let store: StoreOf<AppSettingFeature>
|
let store: StoreOf<AppSettingFeature>
|
||||||
|
// 直接let声明pickerStore
|
||||||
|
let pickerStore = Store(
|
||||||
|
initialState: ImagePickerWithPreviewReducer.State(inner: ImagePickerWithPreviewState(selectionMode: .single)),
|
||||||
|
reducer: { ImagePickerWithPreviewReducer() }
|
||||||
|
)
|
||||||
|
@State private var showNicknameAlert = false
|
||||||
|
@State private var nicknameInput = ""
|
||||||
|
@State private var showImagePickerSheet = false
|
||||||
|
@State private var showActionSheet = false
|
||||||
|
@State private var showPhotoPicker = false
|
||||||
|
@State private var showCamera = false
|
||||||
|
@State private var selectedPhotoItems: [PhotosPickerItem] = []
|
||||||
|
@State private var selectedImages: [UIImage] = []
|
||||||
|
@State private var cameraImage: UIImage? = nil
|
||||||
|
@State private var previewIndex: Int = 0
|
||||||
|
@State private var showPreview = false
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||||
ZStack {
|
WithPerceptionTracking {
|
||||||
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
|
ZStack {
|
||||||
// VStack(spacing: 0) {
|
mainContent(viewStore: viewStore)
|
||||||
// 主要内容
|
}
|
||||||
VStack(spacing: 0) {
|
.confirmationDialog(
|
||||||
// 头像区域
|
"请选择图片来源",
|
||||||
avatarSection(viewStore: viewStore)
|
isPresented: $showActionSheet,
|
||||||
|
titleVisibility: .visible
|
||||||
// 昵称设置项
|
) {
|
||||||
nicknameSection(viewStore: viewStore)
|
Button("拍照") { showCamera = true }
|
||||||
|
Button("从相册选择") { showPhotoPicker = true }
|
||||||
// 其他设置项
|
Button("取消", role: .cancel) {}
|
||||||
settingsSection(viewStore: viewStore)
|
}
|
||||||
|
.photosPicker(
|
||||||
Spacer()
|
isPresented: $showPhotoPicker,
|
||||||
|
selection: $selectedPhotoItems,
|
||||||
// 底部大按钮
|
maxSelectionCount: 1,
|
||||||
logoutButton(viewStore: viewStore)
|
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))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage = "拍照失败,请重试"
|
||||||
|
// 强制关闭所有弹窗
|
||||||
|
showPreview = false
|
||||||
|
showCamera = false
|
||||||
|
showPhotoPicker = false
|
||||||
|
showActionSheet = false
|
||||||
|
print("[LOG] CameraPicker无图片,弹出错误提示")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.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: {
|
||||||
|
// 强制关闭所有弹窗,放到action中,避免在视图更新周期set状态
|
||||||
|
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")!)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(NSLocalizedString("appSetting.title", comment: "Edit"))
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
}
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
// MARK: - 主要内容
|
||||||
.toolbar {
|
private func mainContent(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ZStack {
|
||||||
Button(action: {}) {
|
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
|
||||||
Image(systemName: "chevron.left")
|
VStack(spacing: 0) {
|
||||||
.font(.system(size: 24, weight: .medium))
|
topBar
|
||||||
.foregroundColor(.white)
|
ScrollView {
|
||||||
|
WithPerceptionTracking {
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
// 头像区域
|
||||||
|
avatarSection(viewStore: viewStore)
|
||||||
|
// 昵称设置项
|
||||||
|
nicknameSection(viewStore: viewStore)
|
||||||
|
// 设置项区域
|
||||||
|
settingsSection(viewStore: viewStore)
|
||||||
|
// 退出登录按钮
|
||||||
|
logoutButton(viewStore: viewStore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
viewStore.send(.onAppear)
|
|
||||||
}
|
|
||||||
.webView(
|
|
||||||
isPresented: userAgreementBinding(viewStore: viewStore),
|
|
||||||
url: APIConfiguration.webURL(for: .userAgreement)
|
|
||||||
)
|
|
||||||
.webView(
|
|
||||||
isPresented: privacyPolicyBinding(viewStore: viewStore),
|
|
||||||
url: APIConfiguration.webURL(for: .privacyPolicy)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - 头像区域
|
// MARK: - 头像区域
|
||||||
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
avatarImageView(viewStore: viewStore)
|
avatarImageView(viewStore: viewStore)
|
||||||
cameraButton
|
.onTapGesture {
|
||||||
|
showActionSheet = true
|
||||||
|
}
|
||||||
|
cameraButton(viewStore: viewStore)
|
||||||
}
|
}
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 头像图片视图
|
// MARK: - 头像图片视图
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
if viewStore.isLoadingUserInfo {
|
if viewStore.isUploadingAvatar || viewStore.isLoadingUserInfo {
|
||||||
loadingAvatarView
|
loadingAvatarView
|
||||||
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
|
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
|
||||||
networkAvatarView(url: avatarURL)
|
networkAvatarView(url: avatarURL)
|
||||||
@@ -76,7 +211,7 @@ struct AppSettingView: View {
|
|||||||
defaultAvatarView
|
defaultAvatarView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 加载状态头像
|
// MARK: - 加载状态头像
|
||||||
private var loadingAvatarView: some View {
|
private var loadingAvatarView: some View {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -88,7 +223,7 @@ struct AppSettingView: View {
|
|||||||
.scaleEffect(1.2)
|
.scaleEffect(1.2)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 网络头像
|
// MARK: - 网络头像
|
||||||
private func networkAvatarView(url: URL) -> some View {
|
private func networkAvatarView(url: URL) -> some View {
|
||||||
CachedAsyncImage(url: url.absoluteString) { image in
|
CachedAsyncImage(url: url.absoluteString) { image in
|
||||||
@@ -101,7 +236,7 @@ struct AppSettingView: View {
|
|||||||
.frame(width: 120, height: 120)
|
.frame(width: 120, height: 120)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 默认头像
|
// MARK: - 默认头像
|
||||||
private var defaultAvatarView: some View {
|
private var defaultAvatarView: some View {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -113,10 +248,12 @@ struct AppSettingView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 相机按钮
|
// MARK: - 相机按钮
|
||||||
private var cameraButton: some View {
|
private func cameraButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
Button(action: {}) {
|
Button(action: {
|
||||||
|
showActionSheet = true
|
||||||
|
}) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(Color.purple).frame(width: 36, height: 36)
|
Circle().fill(Color.purple).frame(width: 36, height: 36)
|
||||||
Image(systemName: "camera.fill")
|
Image(systemName: "camera.fill")
|
||||||
@@ -125,7 +262,7 @@ struct AppSettingView: View {
|
|||||||
}
|
}
|
||||||
.offset(x: 8, y: 8)
|
.offset(x: 8, y: 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 昵称设置项
|
// MARK: - 昵称设置项
|
||||||
private func nicknameSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func nicknameSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -141,14 +278,15 @@ struct AppSettingView: View {
|
|||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
viewStore.send(.editNicknameTapped)
|
nicknameInput = viewStore.nickname
|
||||||
|
showNicknameAlert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider().background(Color.gray.opacity(0.3))
|
Divider().background(Color.gray.opacity(0.3))
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 设置项区域
|
// MARK: - 设置项区域
|
||||||
private func settingsSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func settingsSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -161,7 +299,7 @@ struct AppSettingView: View {
|
|||||||
.background(Color.clear)
|
.background(Color.clear)
|
||||||
.padding(.horizontal, 0)
|
.padding(.horizontal, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 个人信息权限行
|
// MARK: - 个人信息权限行
|
||||||
private func personalInfoPermissionsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func personalInfoPermissionsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
settingRow(
|
settingRow(
|
||||||
@@ -169,7 +307,7 @@ struct AppSettingView: View {
|
|||||||
action: { viewStore.send(.personalInfoPermissionsTapped) }
|
action: { viewStore.send(.personalInfoPermissionsTapped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 帮助行
|
// MARK: - 帮助行
|
||||||
private func helpRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func helpRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
settingRow(
|
settingRow(
|
||||||
@@ -177,7 +315,7 @@ struct AppSettingView: View {
|
|||||||
action: { viewStore.send(.helpTapped) }
|
action: { viewStore.send(.helpTapped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 清除缓存行
|
// MARK: - 清除缓存行
|
||||||
private func clearCacheRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func clearCacheRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
settingRow(
|
settingRow(
|
||||||
@@ -185,7 +323,7 @@ struct AppSettingView: View {
|
|||||||
action: { viewStore.send(.clearCacheTapped) }
|
action: { viewStore.send(.clearCacheTapped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 检查更新行
|
// MARK: - 检查更新行
|
||||||
private func checkUpdatesRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func checkUpdatesRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
settingRow(
|
settingRow(
|
||||||
@@ -193,7 +331,7 @@ struct AppSettingView: View {
|
|||||||
action: { viewStore.send(.checkUpdatesTapped) }
|
action: { viewStore.send(.checkUpdatesTapped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 关于我们行
|
// MARK: - 关于我们行
|
||||||
private func aboutUsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func aboutUsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
settingRow(
|
settingRow(
|
||||||
@@ -201,7 +339,7 @@ struct AppSettingView: View {
|
|||||||
action: { viewStore.send(.aboutUsTapped) }
|
action: { viewStore.send(.aboutUsTapped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 设置项行
|
// MARK: - 设置项行
|
||||||
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
|
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -217,12 +355,12 @@ struct AppSettingView: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider().background(Color.gray.opacity(0.3))
|
Divider().background(Color.gray.opacity(0.3))
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 退出登录按钮
|
// MARK: - 退出登录按钮
|
||||||
private func logoutButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
private func logoutButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -239,7 +377,7 @@ struct AppSettingView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 32)
|
.padding(.bottom, 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 用户协议绑定
|
// MARK: - 用户协议绑定
|
||||||
private func userAgreementBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
private func userAgreementBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
||||||
viewStore.binding(
|
viewStore.binding(
|
||||||
@@ -247,7 +385,7 @@ struct AppSettingView: View {
|
|||||||
send: AppSettingFeature.Action.userAgreementDismissed
|
send: AppSettingFeature.Action.userAgreementDismissed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 隐私政策绑定
|
// MARK: - 隐私政策绑定
|
||||||
private func privacyPolicyBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
private func privacyPolicyBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
||||||
viewStore.binding(
|
viewStore.binding(
|
||||||
@@ -255,4 +393,82 @@ struct AppSettingView: View {
|
|||||||
send: AppSettingFeature.Action.privacyPolicyDismissed
|
send: AppSettingFeature.Action.privacyPolicyDismissed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// MARK: - 昵称Alert内容
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -0,0 +1,40 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
public struct CameraPicker: UIViewControllerRepresentable {
|
||||||
|
public var onImagePicked: (UIImage?) -> Void
|
||||||
|
public init(onImagePicked: @escaping (UIImage?) -> Void) {
|
||||||
|
self.onImagePicked = onImagePicked
|
||||||
|
}
|
||||||
|
public func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(onImagePicked: onImagePicked)
|
||||||
|
}
|
||||||
|
public func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = .camera
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
picker.allowsEditing = false
|
||||||
|
picker.cameraViewTransform = .identity
|
||||||
|
picker.showsCameraControls = true
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
public func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||||
|
public class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||||||
|
let onImagePicked: (UIImage?) -> Void
|
||||||
|
init(onImagePicked: @escaping (UIImage?) -> Void) {
|
||||||
|
self.onImagePicked = onImagePicked
|
||||||
|
}
|
||||||
|
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||||
|
let image = info[.originalImage] as? UIImage
|
||||||
|
onImagePicked(image)
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
onImagePicked(nil)
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选:如需自定义相册选择器,可扩展此文件
|
@@ -0,0 +1,140 @@
|
|||||||
|
import Foundation
|
||||||
|
import ComposableArchitecture
|
||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
public enum ImagePickerSelectionMode: Equatable {
|
||||||
|
case single
|
||||||
|
case multiple(max: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ImagePickerWithPreviewState: Equatable {
|
||||||
|
public var selectionMode: ImagePickerSelectionMode = .single
|
||||||
|
public var showActionSheet: Bool = false
|
||||||
|
public var showPhotoPicker: Bool = false
|
||||||
|
public var showCamera: Bool = false
|
||||||
|
public var showPreview: Bool = false
|
||||||
|
public var isLoading: Bool = false
|
||||||
|
public var errorMessage: String? = nil
|
||||||
|
public var selectedPhotoItems: [PhotosPickerItem] = []
|
||||||
|
public var selectedImages: [UIImage] = []
|
||||||
|
public var cameraImage: UIImage? = nil
|
||||||
|
public var previewIndex: Int = 0 // 多选时预览当前索引
|
||||||
|
|
||||||
|
public init(selectionMode: ImagePickerSelectionMode = .single) {
|
||||||
|
self.selectionMode = selectionMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ImagePickerWithPreviewAction: Equatable {
|
||||||
|
case showActionSheet(Bool)
|
||||||
|
case selectSource(ImageSource)
|
||||||
|
case photoPickerItemsChanged([PhotosPickerItem])
|
||||||
|
case cameraImagePicked(UIImage?)
|
||||||
|
case previewConfirm
|
||||||
|
case previewCancel
|
||||||
|
case uploadStart
|
||||||
|
case uploadSuccess
|
||||||
|
case uploadFailure(String)
|
||||||
|
case setLoading(Bool)
|
||||||
|
case setError(String?)
|
||||||
|
case setPreviewIndex(Int)
|
||||||
|
case setShowCamera(Bool)
|
||||||
|
case setShowPhotoPicker(Bool)
|
||||||
|
case reset
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ImageSource: Equatable {
|
||||||
|
case camera
|
||||||
|
case photoLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ImagePickerWithPreviewReducer: Reducer {
|
||||||
|
public init() {}
|
||||||
|
public struct State: Equatable {
|
||||||
|
public var inner: ImagePickerWithPreviewState
|
||||||
|
public init(inner: ImagePickerWithPreviewState) { self.inner = inner }
|
||||||
|
}
|
||||||
|
public enum Action: Equatable {
|
||||||
|
case inner(ImagePickerWithPreviewAction)
|
||||||
|
}
|
||||||
|
public var body: some ReducerOf<Self> {
|
||||||
|
Reduce { state, action in
|
||||||
|
switch action {
|
||||||
|
case .inner(let action):
|
||||||
|
switch action {
|
||||||
|
case .showActionSheet(let show):
|
||||||
|
state.inner.showActionSheet = show
|
||||||
|
return .none
|
||||||
|
case .selectSource(let source):
|
||||||
|
state.inner.showActionSheet = false
|
||||||
|
switch source {
|
||||||
|
case .camera:
|
||||||
|
state.inner.showCamera = true
|
||||||
|
state.inner.showPhotoPicker = false
|
||||||
|
case .photoLibrary:
|
||||||
|
state.inner.showPhotoPicker = true
|
||||||
|
state.inner.showCamera = false
|
||||||
|
}
|
||||||
|
return .none
|
||||||
|
case .photoPickerItemsChanged(let items):
|
||||||
|
state.inner.selectedPhotoItems = items
|
||||||
|
// 选完后进入预览
|
||||||
|
state.inner.showPreview = !items.isEmpty
|
||||||
|
state.inner.previewIndex = 0
|
||||||
|
return .none
|
||||||
|
case .cameraImagePicked(let image):
|
||||||
|
state.inner.cameraImage = image
|
||||||
|
state.inner.selectedImages = image.map { [$0] } ?? []
|
||||||
|
state.inner.showPreview = image != nil
|
||||||
|
state.inner.previewIndex = 0
|
||||||
|
return .none
|
||||||
|
case .previewConfirm:
|
||||||
|
state.inner.showPreview = false
|
||||||
|
state.inner.isLoading = true
|
||||||
|
state.inner.errorMessage = nil
|
||||||
|
return .none // 上传逻辑由外部Effect注入
|
||||||
|
case .previewCancel:
|
||||||
|
state.inner.showPreview = false
|
||||||
|
state.inner.selectedPhotoItems = []
|
||||||
|
state.inner.selectedImages = []
|
||||||
|
state.inner.cameraImage = nil
|
||||||
|
return .none
|
||||||
|
case .uploadStart:
|
||||||
|
state.inner.isLoading = true
|
||||||
|
state.inner.errorMessage = nil
|
||||||
|
return .none
|
||||||
|
case .uploadSuccess:
|
||||||
|
state.inner.isLoading = false
|
||||||
|
state.inner.selectedPhotoItems = []
|
||||||
|
state.inner.selectedImages = []
|
||||||
|
state.inner.cameraImage = nil
|
||||||
|
return .none
|
||||||
|
case .uploadFailure(let msg):
|
||||||
|
state.inner.isLoading = false
|
||||||
|
state.inner.errorMessage = msg
|
||||||
|
return .none
|
||||||
|
case .setLoading(let loading):
|
||||||
|
state.inner.isLoading = loading
|
||||||
|
return .none
|
||||||
|
case .setError(let msg):
|
||||||
|
state.inner.errorMessage = msg
|
||||||
|
return .none
|
||||||
|
case .setPreviewIndex(let idx):
|
||||||
|
state.inner.previewIndex = idx
|
||||||
|
return .none
|
||||||
|
case .setShowCamera(let show):
|
||||||
|
state.inner.showCamera = show
|
||||||
|
return .none
|
||||||
|
case .setShowPhotoPicker(let show):
|
||||||
|
state.inner.showPhotoPicker = show
|
||||||
|
return .none
|
||||||
|
case .reset:
|
||||||
|
let mode = state.inner.selectionMode
|
||||||
|
state.inner = ImagePickerWithPreviewState(selectionMode: mode)
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,190 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
public struct ImagePickerWithPreviewView: View {
|
||||||
|
let store: StoreOf<ImagePickerWithPreviewReducer>
|
||||||
|
let onUpload: ([UIImage]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var loadedImages: [UIImage] = []
|
||||||
|
@State private var isLoadingImages: Bool = false
|
||||||
|
|
||||||
|
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void, onCancel: @escaping () -> Void) {
|
||||||
|
self.store = store
|
||||||
|
self.onUpload = onUpload
|
||||||
|
self.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some 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(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActionSheetModifier: ViewModifier {
|
||||||
|
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
|
||||||
|
let onCancel: () -> Void
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.confirmationDialog(
|
||||||
|
"请选择图片来源",
|
||||||
|
isPresented: .init(
|
||||||
|
get: { viewStore.inner.showActionSheet },
|
||||||
|
set: { viewStore.send(.inner(.showActionSheet($0))) }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("拍照") { viewStore.send(.inner(.selectSource(.camera))) }
|
||||||
|
Button("从相册选择") { viewStore.send(.inner(.selectSource(.photoLibrary))) }
|
||||||
|
Button("取消", role: .cancel) { onCancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CameraSheetModifier: ViewModifier {
|
||||||
|
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.sheet(isPresented: .init(
|
||||||
|
get: { viewStore.inner.showCamera },
|
||||||
|
set: { viewStore.send(.inner(.setShowCamera($0))) }
|
||||||
|
)) {
|
||||||
|
CameraPicker { image in
|
||||||
|
viewStore.send(.inner(.cameraImagePicked(image)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PhotosPickerModifier: ViewModifier {
|
||||||
|
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
|
||||||
|
@Binding var loadedImages: [UIImage]
|
||||||
|
@Binding var isLoadingImages: Bool
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.photosPicker(
|
||||||
|
isPresented: .init(
|
||||||
|
get: { viewStore.inner.showPhotoPicker },
|
||||||
|
set: { viewStore.send(.inner(.setShowPhotoPicker($0))) }
|
||||||
|
),
|
||||||
|
selection: .init(
|
||||||
|
get: { viewStore.inner.selectedPhotoItems },
|
||||||
|
set: { viewStore.send(.inner(.photoPickerItemsChanged($0))) }
|
||||||
|
),
|
||||||
|
maxSelectionCount: {
|
||||||
|
switch viewStore.inner.selectionMode {
|
||||||
|
case .single: return 1
|
||||||
|
case .multiple(let max): return max
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
matching: .images
|
||||||
|
)
|
||||||
|
.onChange(of: viewStore.inner.selectedPhotoItems) { items in
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
isLoadingImages = true
|
||||||
|
loadedImages = []
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
group.wait()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
loadedImages = tempImages
|
||||||
|
isLoadingImages = false
|
||||||
|
viewStore.send(.inner(.setLoading(false)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreviewCoverModifier: ViewModifier {
|
||||||
|
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
|
||||||
|
let loadedImages: [UIImage]
|
||||||
|
let onUpload: ([UIImage]) -> Void
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.fullScreenCover(isPresented: .init(
|
||||||
|
get: { viewStore.inner.showPreview },
|
||||||
|
set: { _ in }
|
||||||
|
)) {
|
||||||
|
ImagePreviewView(
|
||||||
|
images: .constant(previewImages),
|
||||||
|
currentIndex: .init(
|
||||||
|
get: { viewStore.inner.previewIndex },
|
||||||
|
set: { viewStore.send(.inner(.setPreviewIndex($0))) }
|
||||||
|
),
|
||||||
|
onConfirm: {
|
||||||
|
viewStore.send(.inner(.previewConfirm))
|
||||||
|
onUpload(previewImages)
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
viewStore.send(.inner(.previewCancel))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var previewImages: [UIImage] {
|
||||||
|
if let camera = viewStore.inner.cameraImage {
|
||||||
|
return [camera]
|
||||||
|
}
|
||||||
|
if !loadedImages.isEmpty {
|
||||||
|
return loadedImages
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ErrorToastModifier: ViewModifier {
|
||||||
|
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.overlay(
|
||||||
|
Group {
|
||||||
|
if let error = viewStore.inner.errorMessage {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding()
|
||||||
|
.background(Color.white)
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct ImagePreviewView: View {
|
||||||
|
@Binding var images: [UIImage]
|
||||||
|
@Binding var currentIndex: Int
|
||||||
|
let onConfirm: () -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
public init(images: Binding<[UIImage]>, currentIndex: Binding<Int>, onConfirm: @escaping () -> Void, onCancel: @escaping () -> Void) {
|
||||||
|
self._images = images
|
||||||
|
self._currentIndex = currentIndex
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
self.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
if !images.isEmpty {
|
||||||
|
TabView(selection: $currentIndex) {
|
||||||
|
ForEach(images.indices, id: \ .self) { idx in
|
||||||
|
Image(uiImage: images[idx])
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.tag(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(PageTabViewStyle(indexDisplayMode: images.count > 1 ? .always : .never))
|
||||||
|
.frame(maxHeight: 400)
|
||||||
|
} else {
|
||||||
|
// 显示加载状态
|
||||||
|
VStack {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
Text("加载图片中...")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.gray.opacity(0.5))
|
||||||
|
.cornerRadius(20)
|
||||||
|
}
|
||||||
|
Button(action: onConfirm) {
|
||||||
|
Text("确认")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.blue)
|
||||||
|
.cornerRadius(20)
|
||||||
|
}
|
||||||
|
.disabled(images.isEmpty)
|
||||||
|
.opacity(images.isEmpty ? 0.5 : 1.0)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -7,108 +7,105 @@ struct OptimizedDynamicCardView: View {
|
|||||||
let moment: MomentsInfo
|
let moment: MomentsInfo
|
||||||
let allMoments: [MomentsInfo]
|
let allMoments: [MomentsInfo]
|
||||||
let currentIndex: Int
|
let currentIndex: Int
|
||||||
|
// 新增:图片点击回调
|
||||||
|
let onImageTap: (_ images: [String], _ index: Int) -> Void
|
||||||
|
|
||||||
// 预览相关状态
|
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void) {
|
||||||
@State private var showPreview = false
|
|
||||||
@State private var previewImageUrls: [String] = []
|
|
||||||
@State private var previewIndex: Int = 0
|
|
||||||
|
|
||||||
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int) {
|
|
||||||
self.moment = moment
|
self.moment = moment
|
||||||
self.allMoments = allMoments
|
self.allMoments = allMoments
|
||||||
self.currentIndex = currentIndex
|
self.currentIndex = currentIndex
|
||||||
|
self.onImageTap = onImageTap
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
ZStack {
|
||||||
// 用户信息
|
// 背景层
|
||||||
HStack(alignment: .top) {
|
RoundedRectangle(cornerRadius: 12)
|
||||||
// 头像
|
.fill(Color.clear)
|
||||||
CachedAsyncImage(url: moment.avatar) { image in
|
.overlay(
|
||||||
image
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.resizable()
|
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||||
.aspectRatio(contentMode: .fill)
|
)
|
||||||
} placeholder: {
|
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
|
||||||
Circle()
|
// 内容层
|
||||||
.fill(Color.gray.opacity(0.3))
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.overlay(
|
// 用户信息
|
||||||
Text(String(moment.nick.prefix(1)))
|
HStack(alignment: .top) {
|
||||||
.font(.system(size: 16, weight: .medium))
|
// 头像
|
||||||
.foregroundColor(.white)
|
CachedAsyncImage(url: moment.avatar) { image in
|
||||||
)
|
image
|
||||||
}
|
.resizable()
|
||||||
.frame(width: 40, height: 40)
|
.aspectRatio(contentMode: .fill)
|
||||||
.clipShape(Circle())
|
} placeholder: {
|
||||||
|
Circle()
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.fill(Color.gray.opacity(0.3))
|
||||||
Text(moment.nick)
|
.overlay(
|
||||||
.font(.system(size: 16, weight: .medium))
|
Text(String(moment.nick.prefix(1)))
|
||||||
.foregroundColor(.white)
|
.font(.system(size: 16, weight: .medium))
|
||||||
Text("ID: \(moment.uid)")
|
.foregroundColor(.white)
|
||||||
.font(.system(size: 12))
|
)
|
||||||
.foregroundColor(.white.opacity(0.6))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
// 时间(原VIP位置)
|
|
||||||
Text(formatDisplayTime(moment.publishTime))
|
|
||||||
.font(.system(size: 12, weight: .bold))
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color.white.opacity(0.15))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动态内容
|
|
||||||
if !moment.content.isEmpty {
|
|
||||||
Text(moment.content)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(.white.opacity(0.9))
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
.padding(.leading, 40 + 8) // 与用户名左边对齐
|
|
||||||
}
|
|
||||||
|
|
||||||
// 优化的图片网格
|
|
||||||
if let images = moment.dynamicResList, !images.isEmpty {
|
|
||||||
OptimizedImageGrid(images: images) { tappedIndex in
|
|
||||||
previewImageUrls = images.map { $0.resUrl ?? "" }
|
|
||||||
previewIndex = tappedIndex
|
|
||||||
showPreview = true
|
|
||||||
}
|
|
||||||
.padding(.bottom, images.count == 2 ? 16 : 0) // 两张图片时增加底部间距
|
|
||||||
}
|
|
||||||
|
|
||||||
// 互动按钮
|
|
||||||
HStack(spacing: 20) {
|
|
||||||
// Like 按钮左对齐
|
|
||||||
Button(action: {}) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
Text("\(moment.likeCount)")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
}
|
}
|
||||||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(moment.nick)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("ID: \(moment.uid)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
// 时间(原VIP位置)
|
||||||
|
Text(formatDisplayTime(moment.publishTime))
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.white.opacity(0.15))
|
||||||
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
|
// 动态内容
|
||||||
|
if !moment.content.isEmpty {
|
||||||
|
Text(moment.content)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.leading, 40 + 8) // 与用户名左边对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化的图片网格
|
||||||
|
if let images = moment.dynamicResList, !images.isEmpty {
|
||||||
|
OptimizedImageGrid(images: images) { tappedIndex in
|
||||||
|
let urls = images.map { $0.resUrl ?? "" }
|
||||||
|
onImageTap(urls, tappedIndex)
|
||||||
|
}
|
||||||
|
.padding(.bottom, images.count == 2 ? 46 : 0) // 两张图片时增加底部间距
|
||||||
|
}
|
||||||
|
|
||||||
|
// 互动按钮
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Like 按钮左对齐
|
||||||
|
Button(action: {}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text("\(moment.likeCount)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(16)
|
||||||
}
|
}
|
||||||
.padding(16)
|
|
||||||
.background(
|
|
||||||
Color.white.opacity(0.1)
|
|
||||||
.cornerRadius(12)
|
|
||||||
)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
preloadNearbyImages()
|
preloadNearbyImages()
|
||||||
}
|
}
|
||||||
// 图片预览弹窗
|
|
||||||
.fullScreenCover(isPresented: $showPreview) {
|
|
||||||
ImagePreviewPager(images: previewImageUrls, currentIndex: $previewIndex) {
|
|
||||||
showPreview = false
|
|
||||||
previewImageUrls = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTime(_ timestamp: Int) -> String {
|
private func formatTime(_ timestamp: Int) -> String {
|
||||||
|
7
yana/Views/Components/PreviewItem.swift
Normal file
7
yana/Views/Components/PreviewItem.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PreviewItem: Identifiable, Equatable {
|
||||||
|
let id = UUID()
|
||||||
|
let images: [String]
|
||||||
|
let index: Int
|
||||||
|
}
|
@@ -1,13 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
//import OptimizedDynamicCardView // 导入新组件
|
|
||||||
|
|
||||||
struct FeedListView: View {
|
struct FeedListView: View {
|
||||||
let store: StoreOf<FeedListFeature>
|
let store: StoreOf<FeedListFeature>
|
||||||
|
// 新增:图片预览状态
|
||||||
|
@State private var previewItem: PreviewItem? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
GeometryReader { geometry in
|
WithPerceptionTracking {
|
||||||
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
// 背景图片
|
// 背景图片
|
||||||
Image("bg")
|
Image("bg")
|
||||||
@@ -18,32 +20,35 @@ struct FeedListView: View {
|
|||||||
.ignoresSafeArea(.all)
|
.ignoresSafeArea(.all)
|
||||||
VStack(alignment: .center, spacing: 0) {
|
VStack(alignment: .center, spacing: 0) {
|
||||||
// 顶部栏
|
// 顶部栏
|
||||||
HStack {
|
ZStack {
|
||||||
Spacer(minLength: 0)
|
HStack {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Button(action: {
|
}
|
||||||
viewStore.send(.editFeedButtonTapped)
|
HStack {
|
||||||
}) {
|
Spacer(minLength: 0)
|
||||||
Image("add icon")
|
Button(action: {
|
||||||
.resizable()
|
viewStore.send(.editFeedButtonTapped)
|
||||||
.frame(width: 36, height: 36)
|
}) {
|
||||||
|
Image("add icon")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, geometry.safeAreaInsets.top)
|
|
||||||
// 其他内容
|
// 其他内容
|
||||||
Image(systemName: "heart.fill")
|
Image("Volume")
|
||||||
.font(.system(size: 60))
|
.frame(width: 56, height: 41)
|
||||||
.foregroundColor(.red)
|
.padding(.top, 16)
|
||||||
.padding(.top, 40)
|
|
||||||
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(NSLocalizedString("feedList.slogan", comment: "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable."))
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.white.opacity(0.9))
|
.foregroundColor(.white.opacity(0.9))
|
||||||
.padding(.horizontal, 30)
|
.padding(.horizontal, 30)
|
||||||
.padding(.bottom, 30)
|
.padding(.bottom, 30)
|
||||||
@@ -66,28 +71,39 @@ struct FeedListView: View {
|
|||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 16) {
|
WithPerceptionTracking {
|
||||||
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
LazyVStack(spacing: 16) {
|
||||||
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
|
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
||||||
// 上拉加载更多触发点
|
OptimizedDynamicCardView(
|
||||||
if index == viewStore.moments.count - 1, viewStore.hasMore, !viewStore.isLoadingMore {
|
moment: moment,
|
||||||
Color.clear
|
allMoments: viewStore.moments,
|
||||||
.frame(height: 1)
|
currentIndex: index,
|
||||||
.onAppear {
|
onImageTap: { images, tappedIndex in
|
||||||
viewStore.send(.loadMore)
|
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)
|
||||||
if viewStore.isLoadingMore {
|
.padding(.top, 10)
|
||||||
ProgressView()
|
.padding(.bottom, 20)
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
viewStore.send(.reload)
|
viewStore.send(.reload)
|
||||||
@@ -119,6 +135,13 @@ struct FeedListView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 新增:图片预览弹窗
|
||||||
|
.fullScreenCover(item: $previewItem) { item in
|
||||||
|
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
|
||||||
|
previewItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -95,7 +95,7 @@ struct LanguageSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func testCOToken() async {
|
private func testCOToken() async {
|
||||||
do {
|
// do {
|
||||||
let token = await cosManager.getToken(apiService: apiService)
|
let token = await cosManager.getToken(apiService: apiService)
|
||||||
if let token = token {
|
if let token = token {
|
||||||
print("✅ Token 测试成功")
|
print("✅ Token 测试成功")
|
||||||
@@ -105,9 +105,9 @@ struct LanguageSettingsView: View {
|
|||||||
} else {
|
} else {
|
||||||
print("❌ Token 测试失败: 未能获取 Token")
|
print("❌ Token 测试失败: 未能获取 Token")
|
||||||
}
|
}
|
||||||
} catch {
|
// } catch {
|
||||||
print("❌ Token 测试异常: \(error.localizedDescription)")
|
// print("❌ Token 测试异常: \(error.localizedDescription)")
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -7,91 +7,123 @@ struct MainView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
InternalMainView(store: store)
|
WithPerceptionTracking {
|
||||||
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
|
InternalMainView(store: store)
|
||||||
if isLoggedOut {
|
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
|
||||||
onLogout?()
|
if isLoggedOut {
|
||||||
|
onLogout?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原 MainView 内容,重命名为 InternalMainView
|
|
||||||
struct InternalMainView: View {
|
struct InternalMainView: View {
|
||||||
let store: StoreOf<MainFeature>
|
let store: StoreOf<MainFeature>
|
||||||
|
@State private var path: [MainFeature.Destination] = []
|
||||||
|
init(store: StoreOf<MainFeature>) {
|
||||||
|
self.store = store
|
||||||
|
_path = State(initialValue: store.withState { $0.navigationPath })
|
||||||
|
}
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
NavigationStack(path: viewStore.binding(get: \.navigationPath, send: MainFeature.Action.navigationPathChanged)) {
|
WithPerceptionTracking {
|
||||||
GeometryReader { geometry in
|
NavigationStack(path: $path) {
|
||||||
ZStack {
|
GeometryReader { geometry in
|
||||||
// 背景图片
|
contentView(geometry: geometry, viewStore: viewStore)
|
||||||
Image("bg")
|
.navigationDestination(for: MainFeature.Destination.self) { destination in
|
||||||
.resizable()
|
DestinationView(destination: destination, store: self.store)
|
||||||
.aspectRatio(contentMode: .fill)
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.onChange(of: path) { newPath in
|
||||||
.clipped()
|
viewStore.send(.navigationPathChanged(newPath))
|
||||||
.ignoresSafeArea(.all)
|
}
|
||||||
// 主内容
|
.onChange(of: viewStore.navigationPath) { newPath in
|
||||||
ZStack {
|
if path != newPath {
|
||||||
FeedListView(store: store.scope(
|
path = newPath
|
||||||
state: \.feedList,
|
|
||||||
action: \.feedList
|
|
||||||
))
|
|
||||||
.isHidden(viewStore.selectedTab != .feed)
|
|
||||||
MeView(
|
|
||||||
store: store.scope(
|
|
||||||
state: \.me,
|
|
||||||
action: \.me
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.isHidden(viewStore.selectedTab != .other)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
// 底部导航栏
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
BottomTabView(selectedTab: viewStore.binding(
|
|
||||||
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
|
|
||||||
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
|
|
||||||
))
|
|
||||||
}
|
|
||||||
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationDestination(for: MainFeature.Destination.self) { destination in
|
|
||||||
switch destination {
|
|
||||||
case .test:
|
|
||||||
TestPushView()
|
|
||||||
case .appSetting:
|
|
||||||
IfLetStore(
|
|
||||||
self.store.scope(
|
|
||||||
state: \.appSettingState,
|
|
||||||
action: MainFeature.Action.appSettingAction
|
|
||||||
),
|
|
||||||
then: { appSettingStore in
|
|
||||||
WithPerceptionTracking {
|
|
||||||
AppSettingView(store: appSettingStore)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
.onAppear {
|
||||||
|
viewStore.send(.onAppear)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
}
|
||||||
viewStore.send(.onAppear)
|
}
|
||||||
|
|
||||||
|
struct DestinationView: View {
|
||||||
|
let destination: MainFeature.Destination
|
||||||
|
let store: StoreOf<MainFeature>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch destination {
|
||||||
|
case .appSetting:
|
||||||
|
IfLetStore(
|
||||||
|
store.scope(state: \.appSettingState, action: \.appSettingAction),
|
||||||
|
then: { store in
|
||||||
|
WithPerceptionTracking {
|
||||||
|
AppSettingView(store: store)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
else: { Text("appSettingState is nil") }
|
||||||
|
)
|
||||||
|
case .testView:
|
||||||
|
TestView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func contentView(geometry: GeometryProxy, viewStore: ViewStoreOf<MainFeature>) -> some View {
|
||||||
|
WithPerceptionTracking {
|
||||||
|
ZStack {
|
||||||
|
// 背景图片
|
||||||
|
Image("bg")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.clipped()
|
||||||
|
.ignoresSafeArea(.all)
|
||||||
|
// 主内容
|
||||||
|
MainContentView(
|
||||||
|
store: store,
|
||||||
|
selectedTab: viewStore.selectedTab
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
// 底部导航栏
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
BottomTabView(selectedTab: viewStore.binding(
|
||||||
|
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
|
||||||
|
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TestPushView: View {
|
struct MainContentView: View {
|
||||||
|
let store: StoreOf<MainFeature>
|
||||||
|
let selectedTab: MainFeature.Tab
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
Group {
|
||||||
Color.blue.ignoresSafeArea()
|
if selectedTab == .feed {
|
||||||
Text("Test Push View")
|
FeedListView(store: store.scope(
|
||||||
.font(.largeTitle)
|
state: \.feedList,
|
||||||
.foregroundColor(.white)
|
action: \.feedList
|
||||||
|
))
|
||||||
|
} else if selectedTab == .other {
|
||||||
|
MeView(
|
||||||
|
store: store.scope(
|
||||||
|
state: \.me,
|
||||||
|
action: \.me
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,9 +3,12 @@ import ComposableArchitecture
|
|||||||
|
|
||||||
struct MeView: View {
|
struct MeView: View {
|
||||||
let store: StoreOf<MeFeature>
|
let store: StoreOf<MeFeature>
|
||||||
|
// 新增:图片预览状态
|
||||||
|
@State private var previewItem: PreviewItem? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
WithPerceptionTracking {
|
||||||
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
Image("bg")
|
Image("bg")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -13,8 +16,8 @@ struct MeView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.clipped()
|
.clipped()
|
||||||
.ignoresSafeArea(.all)
|
.ignoresSafeArea(.all)
|
||||||
VStack(spacing: 0) {
|
// 顶部栏,右上角设置按钮
|
||||||
// 顶部栏,右上角设置按钮
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
@@ -22,13 +25,16 @@ struct MeView: View {
|
|||||||
viewStore.send(.settingButtonTapped)
|
viewStore.send(.settingButtonTapped)
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
.font(.system(size: 22, weight: .medium))
|
.font(.system(size: 33, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
VStack(spacing: 16) {
|
||||||
// 用户信息区域
|
// 用户信息区域
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
if viewStore.isLoadingUserInfo {
|
if viewStore.isLoadingUserInfo {
|
||||||
@@ -60,6 +66,7 @@ struct MeView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.7))
|
.foregroundColor(.white.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
.padding(.top, 0)
|
||||||
.frame(height: 130)
|
.frame(height: 130)
|
||||||
} else {
|
} else {
|
||||||
Spacer().frame(height: 130)
|
Spacer().frame(height: 130)
|
||||||
@@ -93,19 +100,31 @@ struct MeView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
WithPerceptionTracking {
|
||||||
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
LazyVStack(spacing: 12) {
|
||||||
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
|
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)
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
if viewStore.hasMore {
|
||||||
|
ProgressView()
|
||||||
|
.onAppear {
|
||||||
|
viewStore.send(.loadMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 新增底部间距
|
||||||
|
Color.clear.frame(height: 120)
|
||||||
}
|
}
|
||||||
if viewStore.hasMore {
|
.padding(.top, 8)
|
||||||
ProgressView()
|
|
||||||
.onAppear {
|
|
||||||
viewStore.send(.loadMore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
viewStore.send(.refresh)
|
viewStore.send(.refresh)
|
||||||
@@ -115,10 +134,18 @@ struct MeView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
ViewStore(self.store, observe: { $0 }).send(.onAppear)
|
||||||
|
}
|
||||||
|
// 新增:图片预览弹窗
|
||||||
|
.fullScreenCover(item: $previewItem) { item in
|
||||||
|
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
|
||||||
|
previewItem = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
ViewStore(self.store, observe: { $0 }).send(.onAppear)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
61
yana/Views/TEST/TestView.swift
Normal file
61
yana/Views/TEST/TestView.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TestView: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 背景色
|
||||||
|
Color.purple.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 30) {
|
||||||
|
// 标题
|
||||||
|
Text("测试页面")
|
||||||
|
.font(.system(size: 32, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
// 描述文本
|
||||||
|
Text("这是一个测试用的页面\n用于验证导航跳转功能")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// 测试按钮
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
TestView()
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user