9 Commits

Author SHA1 Message Date
edwinQQQ
a37d7c6eb8 feat: 更新AppSettingView和ImagePicker组件以增强图片选择与预览体验
- 在AppSettingView中修复selectedImages的绑定,确保预览组件正确接收图片。
- 在ImagePreviewView中将images参数改为@Binding类型,提升数据流动性。
- 在ImagePickerWithPreviewView中使用.constant修饰符,优化预览逻辑。
- 在CameraPicker中添加相机控制显示和视图变换设置,提升用户体验。
- 在ImagePreviewView中添加加载状态提示,改善用户反馈。
2025-07-26 09:55:23 +08:00
edwinQQQ
bc96cc47ff feat: 优化AppSettingView和ImagePicker组件以增强图片选择体验
- 在AppSettingView中添加详细的日志记录,便于调试和用户反馈。
- 改进图片加载逻辑,确保在加载失败时提供用户友好的错误提示。
- 在ImagePickerWithPreview组件中使用主线程更新UI,提升响应速度。
- 更新错误弹窗逻辑,确保在用户确认后关闭所有相关弹窗,优化用户体验。
2025-07-26 09:43:10 +08:00
edwinQQQ
ac0d622c97 feat: 更新Info.plist和AppSettingView以支持相机和图片选择功能
- 在Info.plist中新增相机使用说明,确保应用能够访问相机功能。
- 在AppSettingFeature中新增showImagePicker状态和setShowImagePicker动作,支持图片选择弹窗的显示。
- 在AppSettingView中整合图片选择与预览功能,优化用户体验。
- 更新MainView以简化导航逻辑,提升代码可读性与维护性。
- 在ImagePickerWithPreview组件中实现相机和相册选择功能,增强交互性。
2025-07-25 18:47:11 +08:00
edwinQQQ
2f3ef22ce5 feat: 更新AppSettingView以集成图片选择与预览功能
- 将图片选择功能整合到AppSettingView中,使用ImagePickerWithPreviewView提升用户体验。
- 移除冗余的照片选择处理逻辑,简化代码结构。
- 更新昵称编辑功能的实现,确保用户输入限制在15个字符内。
- 优化导航栏和用户协议、隐私政策的展示,增强界面交互性。
2025-07-25 17:20:31 +08:00
edwinQQQ
2cfdf110af feat: 新增图片选择与预览功能
- 在ImagePickerWithPreview组件中实现相机和相册选择功能,提升用户体验。
- 新增ImagePreviewView以支持图片预览,增强交互性。
- 更新MainView以移除冗余调试日志,优化代码整洁性。
- 在swift-assistant-style.mdc中添加项目基础信息,确保开发环境一致性。
2025-07-25 17:08:05 +08:00
edwinQQQ
79fc03b52a feat: 优化视图组件与数据迁移逻辑
- 移除DataMigrationManager类,简化数据迁移逻辑。
- 在FeedListView和MeView中新增图片预览功能,提升用户体验。
- 更新OptimizedDynamicCardView以支持图片点击回调,增强交互性。
- 新增PreviewItem结构体以管理图片预览状态,提升代码可读性与维护性。
- 清理AppDelegate中的冗余代码,优化启动流程。
2025-07-25 16:22:38 +08:00
edwinQQQ
815091a2ff feat: 新增返回按钮功能以优化设置页面导航
- 在AppSettingFeature中新增dismissTapped事件,处理返回操作。
- 更新MainFeature以监听dismissTapped事件,支持导航栈的pop操作。
- 在AppSettingView中实现返回按钮,提升用户体验与界面交互性。
- 隐藏导航栏以优化设置页面的视觉效果。
2025-07-25 14:43:27 +08:00
edwinQQQ
fb09ddb956 feat: 增强应用功能与用户体验
- 在Package.swift中更新依赖路径,确保项目结构清晰。
- 在AppSettingFeature中新增初始化方法,支持用户信息、头像和昵称的设置。
- 更新FeedListFeature和MainFeature,新增测试按钮和导航功能,提升用户交互体验。
- 在MeFeature中优化用户信息加载逻辑,增强错误处理能力。
- 新增TestView以支持测试功能,验证导航跳转的有效性。
- 更新多个视图以整合新功能,提升代码可读性与维护性。
2025-07-25 14:10:56 +08:00
edwinQQQ
343fd9e2df feat: 新增用户信息更新功能
- 在APIEndpoints中新增用户信息更新端点。
- 实现UpdateUserRequest和UpdateUserResponse结构体,支持用户信息更新请求和响应。
- 在APIService中添加updateUser方法,处理用户信息更新请求。
- 更新AppSettingFeature以支持头像和昵称的修改,整合用户信息更新逻辑。
- 在AppSettingView中实现头像选择和昵称编辑功能,提升用户体验。
2025-07-24 16:38:27 +08:00
29 changed files with 1390 additions and 831 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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",
),
]
)
)

View File

@@ -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";
};

View File

@@ -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"
}
},
{

View File

@@ -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"

View File

@@ -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?
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
// 🔍 DESOC
// print("🔐 使OCDES")
// 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

View File

@@ -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
// userInfoavatarURLnicknameinit
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()
// FeatureMainFeature
// .noneMainFeatureAppSettingFeature.Action.logoutTapped
}
case .dismissTapped:
// MainFeature navigationPath pop
return .none
case .editNicknameTapped:
//
return .none
case .logoutTapped:
//
return .run { send in
await UserInfoManager.clearAllAuthenticationData()
// FeatureMainFeature
// .noneMainFeatureAppSettingFeature.Action.logoutTapped
case .loadUserInfo:
state.isLoadingUserInfo = true
state.userInfoError = nil
return .run { send in
// do {
if let userInfo = await UserInfoManager.getUserInfo() {
await send(.userInfoResponse(.success(userInfo)))
} else {
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
}
// } catch {
// let apiError: APIError
// if let error = error as? APIError {
// apiError = error
// } else {
// apiError = APIError.custom(error.localizedDescription)
// }
// await send(.userInfoResponse(.failure(apiError)))
// }
}
case let .userInfoResponse(.success(userInfo)):
state.userInfo = userInfo
state.nickname = userInfo.nick ?? ""
state.avatarURL = userInfo.avatar //
state.isLoadingUserInfo = false
return .none
case let .userInfoResponse(.failure(error)):
state.userInfoError = error.localizedDescription
state.isLoadingUserInfo = false
return .none
case .personalInfoPermissionsTapped:
state.showPrivacyPolicy = true
return .none
case .helpTapped:
state.showUserAgreement = true
return .none
case .clearCacheTapped:
//
return .none
case .checkUpdatesTapped:
//
return .none
case .aboutUsTapped:
//
return .none
case .userAgreementDismissed:
state.showUserAgreement = false
return .none
case .privacyPolicyDismissed:
state.showPrivacyPolicy = false
return .none
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 }
// avatarURLUI
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 userinfo1
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
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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 {
}
}
}
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -27,7 +27,7 @@ struct AppRootView: View {
}
}
}
#Preview {
AppRootView()
}
//
//#Preview {
// AppRootView()
//}

View File

@@ -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>
// letpickerStore
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: {
// actionset
showPreview = false
showCamera = false
showPhotoPicker = false
showActionSheet = false
}))
}
.navigationBarHidden(true)
.alert("修改昵称", isPresented: $showNicknameAlert) {
nicknameAlertContent(viewStore: viewStore)
} message: {
Text("昵称最长15个字符")
}
.sheet(isPresented: userAgreementBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/user-agreement")!)
}
.sheet(isPresented: privacyPolicyBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/privacy-policy")!)
}
}
.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))
}
}

View File

@@ -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)
}
}
}
//

View File

@@ -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
}
}
}
}
}

View File

@@ -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)
}
}
}
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 {

View File

@@ -0,0 +1,7 @@
import Foundation
struct PreviewItem: Identifiable, Equatable {
let id = UUID()
let images: [String]
let index: Int
}

View File

@@ -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
}
}
}
}
}
}
}

View File

@@ -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)")
// }
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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)
}
}
}

View 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()
}
}