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
|
||||
|
||||
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.
|
||||
|
||||
## OBJECTIVE
|
||||
|
@@ -9,6 +9,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
@@ -16,18 +16,22 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.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: [
|
||||
.target(
|
||||
name: "yana",
|
||||
dependencies: [
|
||||
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
|
||||
]
|
||||
"LiquidGlass"
|
||||
],
|
||||
path: "yana",
|
||||
),
|
||||
.testTarget(
|
||||
name: "yanaTests",
|
||||
dependencies: ["yana"]
|
||||
dependencies: ["yana"],
|
||||
path: "yanaAPITests",
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
@@ -515,7 +515,7 @@
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
@@ -573,7 +573,7 @@
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
@@ -598,7 +598,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.9;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
||||
};
|
||||
@@ -621,7 +621,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.9;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
||||
};
|
||||
|
@@ -33,8 +33,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
|
||||
"version" : "1.2.0"
|
||||
"revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -88,7 +88,7 @@
|
||||
"location" : "https://github.com/pointfreeco/swift-navigation",
|
||||
"state" : {
|
||||
"revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21",
|
||||
"version" : "2.3.1"
|
||||
"version" : "2.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -24,6 +24,7 @@ enum APIEndpoint: String, CaseIterable {
|
||||
case publishFeed = "/dynamic/square/publish" // 发布动态
|
||||
case getUserInfo = "/user/get" // 新增:获取用户信息端点
|
||||
case getMyDynamic = "/dynamic/getMyDynamic"
|
||||
case updateUser = "/user/v2/update" // 新增:用户信息更新端点
|
||||
|
||||
// Web 页面路径
|
||||
case userAgreement = "/modules/rule/protocol.html"
|
||||
|
@@ -787,7 +787,7 @@ extension UserInfoManager {
|
||||
debugInfoSync("🔄 开始刷新当前用户信息")
|
||||
debugInfoSync(" 当前UID: \(currentUid)")
|
||||
|
||||
if let userInfo = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) {
|
||||
if let _ = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) {
|
||||
debugInfoSync("✅ 用户信息刷新成功")
|
||||
return true
|
||||
} else {
|
||||
@@ -832,7 +832,7 @@ extension UserInfoManager {
|
||||
}
|
||||
|
||||
// 检查是否已有用户信息缓存
|
||||
if let cachedUserInfo = await getUserInfo() {
|
||||
if let _ = await getUserInfo() {
|
||||
debugInfoSync("📱 APP启动:使用现有用户信息缓存")
|
||||
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
|
||||
|
||||
/// 构建完整的请求 URL
|
||||
|
@@ -581,7 +581,7 @@ extension LoginHelper {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||
await debugErrorSync("❌ 邮箱DES加密失败")
|
||||
debugErrorSync("❌ 邮箱DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@@ -3,84 +3,10 @@ import UIKit
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
||||
|
||||
// isPerceptionCheckingEnabled = false
|
||||
|
||||
// 执行数据迁移(从 UserDefaults 到 Keychain)
|
||||
DataMigrationManager.performStartupMigration()
|
||||
|
||||
|
||||
// 预加载用户信息缓存
|
||||
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()
|
||||
|
||||
return true
|
||||
|
@@ -14,12 +14,30 @@ struct AppSettingFeature {
|
||||
// WebView 导航状态
|
||||
var showUserAgreement: 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 {
|
||||
case onAppear
|
||||
case editNicknameTapped
|
||||
case logoutTapped
|
||||
case dismissTapped
|
||||
|
||||
// 用户信息相关
|
||||
case loadUserInfo
|
||||
@@ -35,89 +53,191 @@ struct AppSettingFeature {
|
||||
// WebView 关闭
|
||||
case userAgreementDismissed
|
||||
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
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .onAppear:
|
||||
return .send(.loadUserInfo)
|
||||
func reduce(into state: inout State, action: Action) -> Effect<Action> {
|
||||
switch action {
|
||||
case .onAppear:
|
||||
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:
|
||||
// 预留编辑昵称逻辑
|
||||
return .none
|
||||
|
||||
case .logoutTapped:
|
||||
// 清理所有认证信息,并向上层发送登出事件
|
||||
return .run { send in
|
||||
await UserInfoManager.clearAllAuthenticationData()
|
||||
// 向上层Feature传递登出事件(需在MainFeature处理)
|
||||
// 这里直接返回.none,由MainFeature监听AppSettingFeature.Action.logoutTapped后处理
|
||||
case .loadUserInfo:
|
||||
state.isLoadingUserInfo = true
|
||||
state.userInfoError = nil
|
||||
return .run { send in
|
||||
// do {
|
||||
if let userInfo = await UserInfoManager.getUserInfo() {
|
||||
await send(.userInfoResponse(.success(userInfo)))
|
||||
} else {
|
||||
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
|
||||
}
|
||||
// } catch {
|
||||
// let apiError: APIError
|
||||
// if let error = error as? APIError {
|
||||
// apiError = error
|
||||
// } else {
|
||||
// apiError = APIError.custom(error.localizedDescription)
|
||||
// }
|
||||
// await send(.userInfoResponse(.failure(apiError)))
|
||||
// }
|
||||
}
|
||||
|
||||
case let .userInfoResponse(.success(userInfo)):
|
||||
state.userInfo = userInfo
|
||||
state.nickname = userInfo.nick ?? ""
|
||||
state.avatarURL = userInfo.avatar // 确保同步
|
||||
state.isLoadingUserInfo = false
|
||||
return .none
|
||||
|
||||
case let .userInfoResponse(.failure(error)):
|
||||
state.userInfoError = error.localizedDescription
|
||||
state.isLoadingUserInfo = false
|
||||
return .none
|
||||
|
||||
case .personalInfoPermissionsTapped:
|
||||
state.showPrivacyPolicy = true
|
||||
return .none
|
||||
|
||||
case .helpTapped:
|
||||
state.showUserAgreement = true
|
||||
return .none
|
||||
|
||||
case .clearCacheTapped:
|
||||
// 预留清除缓存逻辑
|
||||
return .none
|
||||
|
||||
case .checkUpdatesTapped:
|
||||
// 预留检查更新逻辑
|
||||
return .none
|
||||
|
||||
case .aboutUsTapped:
|
||||
// 预留关于我们逻辑
|
||||
return .none
|
||||
|
||||
case .userAgreementDismissed:
|
||||
state.showUserAgreement = false
|
||||
return .none
|
||||
|
||||
case .privacyPolicyDismissed:
|
||||
state.showPrivacyPolicy = false
|
||||
return .none
|
||||
|
||||
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:
|
||||
state.isLoadingUserInfo = true
|
||||
state.userInfoError = nil
|
||||
// 上传图片到腾讯云
|
||||
if let url = await COSManager.shared.uploadUIImage(uiImage, apiService: apiService) {
|
||||
await send(.avatarUploadResult(.success(url)))
|
||||
} 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
|
||||
do {
|
||||
if let userInfo = await UserInfoManager.getUserInfo() {
|
||||
await send(.userInfoResponse(.success(userInfo)))
|
||||
} else {
|
||||
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
|
||||
}
|
||||
} catch {
|
||||
let apiError: APIError
|
||||
if let error = error as? APIError {
|
||||
apiError = error
|
||||
} else {
|
||||
apiError = APIError.custom(error.localizedDescription)
|
||||
}
|
||||
await send(.userInfoResponse(.failure(apiError)))
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
if let newUser = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
|
||||
await send(.userInfoResponse(.success(newUser)))
|
||||
} else {
|
||||
await send(.userInfoResponse(.failure(APIError.custom("获取最新用户信息失败"))))
|
||||
}
|
||||
}
|
||||
|
||||
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 editFeedButtonTapped // 新增:点击 add 按钮
|
||||
case editFeedDismissed // 新增:关闭编辑页
|
||||
case testButtonTapped // 新增:点击测试按钮
|
||||
// 新增:动态内容相关
|
||||
case fetchFeeds
|
||||
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
|
||||
@@ -125,6 +126,9 @@ struct FeedListFeature {
|
||||
case .editFeedDismissed:
|
||||
state.isEditFeedPresented = false
|
||||
return .none
|
||||
case .testButtonTapped:
|
||||
debugInfoSync("[LOG] FeedListFeature testButtonTapped")
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,11 +2,13 @@ import Foundation
|
||||
import ComposableArchitecture
|
||||
import CasePaths
|
||||
|
||||
struct MainFeature: Reducer {
|
||||
@Reducer
|
||||
struct MainFeature {
|
||||
enum Tab: Int, Equatable, CaseIterable {
|
||||
case feed, other
|
||||
}
|
||||
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var selectedTab: Tab = .feed
|
||||
var feedList: FeedListFeature.State = .init()
|
||||
@@ -20,9 +22,9 @@ struct MainFeature: Reducer {
|
||||
}
|
||||
|
||||
// 新增:导航目标
|
||||
enum Destination: Hashable, Equatable {
|
||||
case test
|
||||
enum Destination: Hashable, Codable, CaseIterable {
|
||||
case appSetting
|
||||
case testView
|
||||
}
|
||||
|
||||
@CasePathable
|
||||
@@ -34,7 +36,6 @@ struct MainFeature: Reducer {
|
||||
case accountModelLoaded(AccountModel?)
|
||||
// 新增:导航相关
|
||||
case navigationPathChanged([Destination])
|
||||
case testButtonTapped
|
||||
case appSettingButtonTapped
|
||||
case appSettingAction(AppSettingFeature.Action)
|
||||
// 新增:登出
|
||||
@@ -42,10 +43,10 @@ struct MainFeature: Reducer {
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Scope(state: \.feedList, action: \.feedList) {
|
||||
Scope(state: \ .feedList, action: \ .feedList) {
|
||||
FeedListFeature()
|
||||
}
|
||||
Scope(state: \.me, action: \.me) {
|
||||
Scope(state: \ .me, action: \ .me) {
|
||||
MeFeature()
|
||||
}
|
||||
Reduce { state, action in
|
||||
@@ -66,36 +67,48 @@ struct MainFeature: Reducer {
|
||||
return .send(.me(.onAppear))
|
||||
}
|
||||
return .none
|
||||
case .feedList(.testButtonTapped):
|
||||
state.navigationPath.append(.testView)
|
||||
return .none
|
||||
case .feedList:
|
||||
return .none
|
||||
case let .accountModelLoaded(accountModel):
|
||||
state.accountModel = accountModel
|
||||
return .none
|
||||
case .me(.settingButtonTapped):
|
||||
// 触发 push 到设置页
|
||||
state.appSettingState = AppSettingFeature.State()
|
||||
// 触发 push 到设置页,带入当前用户信息
|
||||
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)
|
||||
return .none
|
||||
case .me:
|
||||
return .none
|
||||
case .navigationPathChanged(let newPath):
|
||||
// pop 回来时清空 settingState
|
||||
if !newPath.contains(.appSetting) {
|
||||
state.appSettingState = nil
|
||||
}
|
||||
state.navigationPath = newPath
|
||||
return .none
|
||||
case .testButtonTapped:
|
||||
state.navigationPath.append(.test)
|
||||
return .none
|
||||
case .appSettingButtonTapped:
|
||||
state.appSettingState = AppSettingFeature.State()
|
||||
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)
|
||||
return .none
|
||||
case .appSettingAction(.logoutTapped):
|
||||
// 监听到登出,设置登出标志
|
||||
state.isLoggedOut = true
|
||||
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:
|
||||
return .none
|
||||
case .logout:
|
||||
@@ -104,8 +117,8 @@ struct MainFeature: Reducer {
|
||||
}
|
||||
}
|
||||
// 设置页作用域
|
||||
.ifLet(\ .appSettingState, action: \.appSettingAction) {
|
||||
.ifLet(\ .appSettingState, action: \ .appSettingAction) {
|
||||
AppSettingFeature()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -87,15 +87,15 @@ struct MeFeature {
|
||||
|
||||
private func fetchUserInfo(uid: Int) -> Effect<Action> {
|
||||
.run { send in
|
||||
do {
|
||||
if let userInfo = try await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
|
||||
// do {
|
||||
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
|
||||
await send(.userInfoResponse(.success(userInfo)))
|
||||
} else {
|
||||
await send(.userInfoResponse(.failure(.noData)))
|
||||
}
|
||||
} catch {
|
||||
await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
|
||||
}
|
||||
// } catch {
|
||||
// await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,4 +110,4 @@ struct MeFeature {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,8 @@
|
||||
</dict>
|
||||
<key>NSWiFiUsageDescription</key>
|
||||
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍照上传图片</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<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 {
|
||||
AppRootView()
|
||||
}
|
||||
//
|
||||
//#Preview {
|
||||
// AppRootView()
|
||||
//}
|
||||
|
@@ -1,74 +1,209 @@
|
||||
//
|
||||
// AppSettingView.swift
|
||||
// yana
|
||||
//
|
||||
// Created by Edwin on 2024/11/20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import PhotosUI
|
||||
|
||||
struct AppSettingView: View {
|
||||
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 {
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
ZStack {
|
||||
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
|
||||
// VStack(spacing: 0) {
|
||||
// 主要内容
|
||||
VStack(spacing: 0) {
|
||||
// 头像区域
|
||||
avatarSection(viewStore: viewStore)
|
||||
|
||||
// 昵称设置项
|
||||
nicknameSection(viewStore: viewStore)
|
||||
|
||||
// 其他设置项
|
||||
settingsSection(viewStore: viewStore)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 底部大按钮
|
||||
logoutButton(viewStore: viewStore)
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
WithPerceptionTracking {
|
||||
ZStack {
|
||||
mainContent(viewStore: viewStore)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"请选择图片来源",
|
||||
isPresented: $showActionSheet,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("拍照") { showCamera = true }
|
||||
Button("从相册选择") { showPhotoPicker = true }
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.photosPicker(
|
||||
isPresented: $showPhotoPicker,
|
||||
selection: $selectedPhotoItems,
|
||||
maxSelectionCount: 1,
|
||||
matching: .images
|
||||
)
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker { image in
|
||||
print("[LOG] CameraPicker回调,image: \(image != nil)")
|
||||
if let image = image {
|
||||
print("[LOG] CameraPicker获得图片,直接上传头像")
|
||||
if let data = image.jpegData(compressionQuality: 0.8) {
|
||||
viewStore.send(AppSettingFeature.Action.avatarSelected(data))
|
||||
}
|
||||
} 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)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: {}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主要内容
|
||||
private func mainContent(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
ZStack {
|
||||
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
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: - 头像区域
|
||||
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
avatarImageView(viewStore: viewStore)
|
||||
cameraButton
|
||||
.onTapGesture {
|
||||
showActionSheet = true
|
||||
}
|
||||
cameraButton(viewStore: viewStore)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 头像图片视图
|
||||
@ViewBuilder
|
||||
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
if viewStore.isLoadingUserInfo {
|
||||
if viewStore.isUploadingAvatar || viewStore.isLoadingUserInfo {
|
||||
loadingAvatarView
|
||||
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
|
||||
networkAvatarView(url: avatarURL)
|
||||
@@ -76,7 +211,7 @@ struct AppSettingView: View {
|
||||
defaultAvatarView
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 加载状态头像
|
||||
private var loadingAvatarView: some View {
|
||||
Circle()
|
||||
@@ -88,7 +223,7 @@ struct AppSettingView: View {
|
||||
.scaleEffect(1.2)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 网络头像
|
||||
private func networkAvatarView(url: URL) -> some View {
|
||||
CachedAsyncImage(url: url.absoluteString) { image in
|
||||
@@ -101,7 +236,7 @@ struct AppSettingView: View {
|
||||
.frame(width: 120, height: 120)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 默认头像
|
||||
private var defaultAvatarView: some View {
|
||||
Circle()
|
||||
@@ -113,10 +248,12 @@ struct AppSettingView: View {
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 相机按钮
|
||||
private var cameraButton: some View {
|
||||
Button(action: {}) {
|
||||
private func cameraButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
Button(action: {
|
||||
showActionSheet = true
|
||||
}) {
|
||||
ZStack {
|
||||
Circle().fill(Color.purple).frame(width: 36, height: 36)
|
||||
Image(systemName: "camera.fill")
|
||||
@@ -125,7 +262,7 @@ struct AppSettingView: View {
|
||||
}
|
||||
.offset(x: 8, y: 8)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 昵称设置项
|
||||
private func nicknameSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -141,14 +278,15 @@ struct AppSettingView: View {
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 18)
|
||||
.onTapGesture {
|
||||
viewStore.send(.editNicknameTapped)
|
||||
nicknameInput = viewStore.nickname
|
||||
showNicknameAlert = true
|
||||
}
|
||||
|
||||
|
||||
Divider().background(Color.gray.opacity(0.3))
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 设置项区域
|
||||
private func settingsSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -161,7 +299,7 @@ struct AppSettingView: View {
|
||||
.background(Color.clear)
|
||||
.padding(.horizontal, 0)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 个人信息权限行
|
||||
private func personalInfoPermissionsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
settingRow(
|
||||
@@ -169,7 +307,7 @@ struct AppSettingView: View {
|
||||
action: { viewStore.send(.personalInfoPermissionsTapped) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 帮助行
|
||||
private func helpRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
settingRow(
|
||||
@@ -177,7 +315,7 @@ struct AppSettingView: View {
|
||||
action: { viewStore.send(.helpTapped) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 清除缓存行
|
||||
private func clearCacheRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
settingRow(
|
||||
@@ -185,7 +323,7 @@ struct AppSettingView: View {
|
||||
action: { viewStore.send(.clearCacheTapped) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 检查更新行
|
||||
private func checkUpdatesRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
settingRow(
|
||||
@@ -193,7 +331,7 @@ struct AppSettingView: View {
|
||||
action: { viewStore.send(.checkUpdatesTapped) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 关于我们行
|
||||
private func aboutUsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
settingRow(
|
||||
@@ -201,7 +339,7 @@ struct AppSettingView: View {
|
||||
action: { viewStore.send(.aboutUsTapped) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 设置项行
|
||||
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -217,12 +355,12 @@ struct AppSettingView: View {
|
||||
.onTapGesture {
|
||||
action()
|
||||
}
|
||||
|
||||
|
||||
Divider().background(Color.gray.opacity(0.3))
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 退出登录按钮
|
||||
private func logoutButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||
Button(action: {
|
||||
@@ -239,7 +377,7 @@ struct AppSettingView: View {
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 用户协议绑定
|
||||
private func userAgreementBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
||||
viewStore.binding(
|
||||
@@ -247,7 +385,7 @@ struct AppSettingView: View {
|
||||
send: AppSettingFeature.Action.userAgreementDismissed
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 隐私政策绑定
|
||||
private func privacyPolicyBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
||||
viewStore.binding(
|
||||
@@ -255,4 +393,82 @@ struct AppSettingView: View {
|
||||
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 allMoments: [MomentsInfo]
|
||||
let currentIndex: Int
|
||||
// 新增:图片点击回调
|
||||
let onImageTap: (_ 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) {
|
||||
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void) {
|
||||
self.moment = moment
|
||||
self.allMoments = allMoments
|
||||
self.currentIndex = currentIndex
|
||||
self.onImageTap = onImageTap
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 用户信息
|
||||
HStack(alignment: .top) {
|
||||
// 头像
|
||||
CachedAsyncImage(url: moment.avatar) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.overlay(
|
||||
Text(String(moment.nick.prefix(1)))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(moment.nick)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
Text("ID: \(moment.uid)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
Spacer()
|
||||
// 时间(原VIP位置)
|
||||
Text(formatDisplayTime(moment.publishTime))
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.white.opacity(0.15))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
// 动态内容
|
||||
if !moment.content.isEmpty {
|
||||
Text(moment.content)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(.leading, 40 + 8) // 与用户名左边对齐
|
||||
}
|
||||
|
||||
// 优化的图片网格
|
||||
if let images = moment.dynamicResList, !images.isEmpty {
|
||||
OptimizedImageGrid(images: images) { tappedIndex in
|
||||
previewImageUrls = images.map { $0.resUrl ?? "" }
|
||||
previewIndex = tappedIndex
|
||||
showPreview = true
|
||||
}
|
||||
.padding(.bottom, images.count == 2 ? 16 : 0) // 两张图片时增加底部间距
|
||||
}
|
||||
|
||||
// 互动按钮
|
||||
HStack(spacing: 20) {
|
||||
// Like 按钮左对齐
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.likeCount)")
|
||||
.font(.system(size: 14))
|
||||
ZStack {
|
||||
// 背景层
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
|
||||
// 内容层
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 用户信息
|
||||
HStack(alignment: .top) {
|
||||
// 头像
|
||||
CachedAsyncImage(url: moment.avatar) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.overlay(
|
||||
Text(String(moment.nick.prefix(1)))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
}
|
||||
.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 {
|
||||
preloadNearbyImages()
|
||||
}
|
||||
// 图片预览弹窗
|
||||
.fullScreenCover(isPresented: $showPreview) {
|
||||
ImagePreviewPager(images: previewImageUrls, currentIndex: $previewIndex) {
|
||||
showPreview = false
|
||||
previewImageUrls = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ComposableArchitecture
|
||||
//import OptimizedDynamicCardView // 导入新组件
|
||||
|
||||
struct FeedListView: View {
|
||||
let store: StoreOf<FeedListFeature>
|
||||
// 新增:图片预览状态
|
||||
@State private var previewItem: PreviewItem? = nil
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
GeometryReader { geometry in
|
||||
WithPerceptionTracking {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片
|
||||
Image("bg")
|
||||
@@ -18,32 +20,35 @@ struct FeedListView: View {
|
||||
.ignoresSafeArea(.all)
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
// 顶部栏
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Spacer(minLength: 0)
|
||||
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: {
|
||||
viewStore.send(.editFeedButtonTapped)
|
||||
}) {
|
||||
Image("add icon")
|
||||
.resizable()
|
||||
.frame(width: 36, height: 36)
|
||||
ZStack {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button(action: {
|
||||
viewStore.send(.editFeedButtonTapped)
|
||||
}) {
|
||||
Image("add icon")
|
||||
.resizable()
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, geometry.safeAreaInsets.top)
|
||||
// 其他内容
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 40)
|
||||
Image("Volume")
|
||||
.frame(width: 56, height: 41)
|
||||
.padding(.top, 16)
|
||||
Text(NSLocalizedString("feedList.slogan", comment: "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable."))
|
||||
.font(.system(size: 16))
|
||||
.multilineTextAlignment(.center)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.bottom, 30)
|
||||
@@ -66,28 +71,39 @@ struct FeedListView: View {
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
||||
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
|
||||
// 上拉加载更多触发点
|
||||
if index == viewStore.moments.count - 1, viewStore.hasMore, !viewStore.isLoadingMore {
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.onAppear {
|
||||
viewStore.send(.loadMore)
|
||||
WithPerceptionTracking {
|
||||
LazyVStack(spacing: 16) {
|
||||
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
||||
OptimizedDynamicCardView(
|
||||
moment: moment,
|
||||
allMoments: viewStore.moments,
|
||||
currentIndex: index,
|
||||
onImageTap: { images, tappedIndex in
|
||||
previewItem = PreviewItem(images: images, index: tappedIndex)
|
||||
}
|
||||
)
|
||||
// 上拉加载更多触发点
|
||||
if index == viewStore.moments.count - 1, viewStore.hasMore, !viewStore.isLoadingMore {
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.onAppear {
|
||||
viewStore.send(.loadMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 加载更多指示器
|
||||
if viewStore.isLoadingMore {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
// 新增底部间距
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
// 加载更多指示器
|
||||
if viewStore.isLoadingMore {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.refreshable {
|
||||
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 {
|
||||
do {
|
||||
// do {
|
||||
let token = await cosManager.getToken(apiService: apiService)
|
||||
if let token = token {
|
||||
print("✅ Token 测试成功")
|
||||
@@ -105,9 +105,9 @@ struct LanguageSettingsView: View {
|
||||
} else {
|
||||
print("❌ Token 测试失败: 未能获取 Token")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Token 测试异常: \(error.localizedDescription)")
|
||||
}
|
||||
// } catch {
|
||||
// print("❌ Token 测试异常: \(error.localizedDescription)")
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -7,91 +7,123 @@ struct MainView: View {
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
InternalMainView(store: store)
|
||||
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
|
||||
if isLoggedOut {
|
||||
onLogout?()
|
||||
WithPerceptionTracking {
|
||||
InternalMainView(store: store)
|
||||
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
|
||||
if isLoggedOut {
|
||||
onLogout?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 原 MainView 内容,重命名为 InternalMainView
|
||||
struct InternalMainView: View {
|
||||
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 {
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
NavigationStack(path: viewStore.binding(get: \.navigationPath, send: MainFeature.Action.navigationPathChanged)) {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.ignoresSafeArea(.all)
|
||||
// 主内容
|
||||
ZStack {
|
||||
FeedListView(store: store.scope(
|
||||
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)
|
||||
WithPerceptionTracking {
|
||||
NavigationStack(path: $path) {
|
||||
GeometryReader { geometry in
|
||||
contentView(geometry: geometry, viewStore: viewStore)
|
||||
.navigationDestination(for: MainFeature.Destination.self) { destination in
|
||||
DestinationView(destination: destination, store: self.store)
|
||||
}
|
||||
.onChange(of: path) { newPath in
|
||||
viewStore.send(.navigationPathChanged(newPath))
|
||||
}
|
||||
.onChange(of: viewStore.navigationPath) { newPath in
|
||||
if path != newPath {
|
||||
path = newPath
|
||||
}
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
viewStore.send(.onAppear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
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 {
|
||||
ZStack {
|
||||
Color.blue.ignoresSafeArea()
|
||||
Text("Test Push View")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.white)
|
||||
Group {
|
||||
if selectedTab == .feed {
|
||||
FeedListView(store: store.scope(
|
||||
state: \.feedList,
|
||||
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 {
|
||||
let store: StoreOf<MeFeature>
|
||||
// 新增:图片预览状态
|
||||
@State private var previewItem: PreviewItem? = nil
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
WithPerceptionTracking {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Image("bg")
|
||||
.resizable()
|
||||
@@ -13,8 +16,8 @@ struct MeView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.ignoresSafeArea(.all)
|
||||
VStack(spacing: 0) {
|
||||
// 顶部栏,右上角设置按钮
|
||||
// 顶部栏,右上角设置按钮
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
@@ -22,13 +25,16 @@ struct MeView: View {
|
||||
viewStore.send(.settingButtonTapped)
|
||||
}) {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.font(.system(size: 33, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
VStack(spacing: 16) {
|
||||
// 用户信息区域
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
if viewStore.isLoadingUserInfo {
|
||||
@@ -60,6 +66,7 @@ struct MeView: View {
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.frame(height: 130)
|
||||
} else {
|
||||
Spacer().frame(height: 130)
|
||||
@@ -93,19 +100,31 @@ struct MeView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
||||
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
|
||||
WithPerceptionTracking {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(viewStore.moments.indices, id: \ .self) { index in
|
||||
let moment = viewStore.moments[index]
|
||||
OptimizedDynamicCardView(
|
||||
moment: moment,
|
||||
allMoments: viewStore.moments,
|
||||
currentIndex: index,
|
||||
onImageTap: { images, tappedIndex in
|
||||
previewItem = PreviewItem(images: images, index: tappedIndex)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
if viewStore.hasMore {
|
||||
ProgressView()
|
||||
.onAppear {
|
||||
viewStore.send(.loadMore)
|
||||
}
|
||||
}
|
||||
// 新增底部间距
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
if viewStore.hasMore {
|
||||
ProgressView()
|
||||
.onAppear {
|
||||
viewStore.send(.loadMore)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.refreshable {
|
||||
viewStore.send(.refresh)
|
||||
@@ -115,10 +134,18 @@ struct MeView: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ViewStore(self.store, observe: { $0 }).send(.onAppear)
|
||||
}
|
||||
// 新增:图片预览弹窗
|
||||
.fullScreenCover(item: $previewItem) { item in
|
||||
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
|
||||
previewItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ViewStore(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