feat: 更新文档和视图以支持iOS 17及优化用户体验

- 更新Yana项目文档,调整适用版本至iOS 17,确保与最新开发环境兼容。
- 在多个视图中重构代码,优化状态管理和视图逻辑,提升用户体验。
- 添加默认初始化器以简化状态管理,确保各个Feature的状态一致性。
- 更新视图组件,移除不必要的硬编码,增强代码可读性和维护性。
- 修复多个视图中的逻辑错误,确保功能正常运行。
This commit is contained in:
edwinQQQ
2025-07-29 17:57:42 +08:00
parent 3ec1b1302f
commit 3d00e459e3
28 changed files with 689 additions and 554 deletions

View File

@@ -5,7 +5,7 @@ alwaysApply: true
--- ---
# Background # Background
This project is based on iOS 16.0+, SwiftUI, and TCA 1.20.2 This project is based on iOS 17.0+, SwiftUI, and TCA 1.20.2
I would like advice on using the latest tools and seek step-by-step guidance to fully understand the implementation process. I would like advice on using the latest tools and seek step-by-step guidance to fully understand the implementation process.

View File

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

View File

@@ -49,8 +49,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = { 4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = yanaAPITests; path = yanaAPITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -255,10 +253,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources.sh\"\n";
@@ -272,10 +274,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n";

View File

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

View File

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

View File

@@ -24,7 +24,12 @@ struct AppSettingFeature {
var isUpdatingUser: Bool = false var isUpdatingUser: Bool = false
var updateUserError: String? = nil var updateUserError: String? = nil
// userInfoavatarURLnicknameinit //
init() {
//
}
// userInfoavatarURLnicknameinit
init(nickname: String = "", avatarURL: String? = nil, userInfo: UserInfo? = nil) { init(nickname: String = "", avatarURL: String? = nil, userInfo: UserInfo? = nil) {
self.nickname = nickname self.nickname = nickname
self.avatarURL = avatarURL self.avatarURL = avatarURL

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import ComposableArchitecture
@Reducer @Reducer
struct FeedListFeature { struct FeedListFeature {
@Dependency(\.apiService) var apiService @Dependency(\.apiService) var apiService
@ObservableState
struct State: Equatable { struct State: Equatable {
var isFirstLoad: Bool = true var isFirstLoad: Bool = true
var feeds: [Feed] = [] // feed var feeds: [Feed] = [] // feed
@@ -23,6 +24,10 @@ struct FeedListFeature {
var selectedMoment: MomentsInfo? var selectedMoment: MomentsInfo?
// //
var likeLoadingDynamicIds: Set<Int> = [] var likeLoadingDynamicIds: Set<Int> = []
init() {
//
}
} }
enum Action: Equatable { enum Action: Equatable {

View File

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

View File

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

View File

@@ -34,13 +34,11 @@ struct LoginFeature {
case failed // case failed //
} }
#if DEBUG
init() { init() {
// //
self.account = "" self.account = ""
self.password = "" self.password = ""
} }
#endif
} }
enum Action { enum Action {

View File

@@ -19,6 +19,10 @@ struct MainFeature {
var appSettingState: AppSettingFeature.State? = nil var appSettingState: AppSettingFeature.State? = nil
// //
var isLoggedOut: Bool = false var isLoggedOut: Bool = false
init() {
//
}
} }
// //

View File

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

View File

@@ -4,6 +4,7 @@ import ComposableArchitecture
@Reducer @Reducer
struct MeFeature { struct MeFeature {
@Dependency(\.apiService) var apiService @Dependency(\.apiService) var apiService
@ObservableState
struct State: Equatable { struct State: Equatable {
var isFirstLoad: Bool = true var isFirstLoad: Bool = true
var userInfo: UserInfo? var userInfo: UserInfo?
@@ -21,6 +22,10 @@ struct MeFeature {
// DetailView // DetailView
var showDetail: Bool = false var showDetail: Bool = false
var selectedMoment: MomentsInfo? var selectedMoment: MomentsInfo?
init() {
//
}
} }
enum Action: Equatable { enum Action: Equatable {

View File

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

View File

@@ -4,12 +4,10 @@ import ComposableArchitecture
struct DetailView: View { struct DetailView: View {
@State var store: StoreOf<DetailFeature> @State var store: StoreOf<DetailFeature>
let onLikeSuccess: ((Int, Bool) -> Void)? let onLikeSuccess: ((Int, Bool) -> Void)?
let onDismiss: (() -> Void)? //
init(store: StoreOf<DetailFeature>, onLikeSuccess: ((Int, Bool) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { init(store: StoreOf<DetailFeature>, onLikeSuccess: ((Int, Bool) -> Void)? = nil) {
self.store = store self.store = store
self.onLikeSuccess = onLikeSuccess self.onLikeSuccess = onLikeSuccess
self.onDismiss = onDismiss
} }
var body: some View { var body: some View {
@@ -28,7 +26,7 @@ struct DetailView: View {
showDeleteButton: isCurrentUserDynamic, showDeleteButton: isCurrentUserDynamic,
isDeleteLoading: store.isDeleteLoading, isDeleteLoading: store.isDeleteLoading,
onBack: { onBack: {
onDismiss?() // // onDismiss?() 使 dismiss()
}, },
onDelete: { onDelete: {
store.send(.deleteDynamic) store.send(.deleteDynamic)
@@ -72,7 +70,7 @@ struct DetailView: View {
.onChange(of: store.shouldDismiss) { shouldDismiss in .onChange(of: store.shouldDismiss) { shouldDismiss in
if shouldDismiss { if shouldDismiss {
debugInfoSync("🔍 DetailView: shouldDismiss = true, 调用onDismiss") debugInfoSync("🔍 DetailView: shouldDismiss = true, 调用onDismiss")
onDismiss?() // onDismiss?() 使 dismiss
} }
} }
.fullScreenCover(isPresented: Binding( .fullScreenCover(isPresented: Binding(
@@ -117,11 +115,22 @@ struct CustomNavigationBar: View {
let isDeleteLoading: Bool let isDeleteLoading: Bool
let onBack: () -> Void let onBack: () -> Void
let onDelete: () -> Void let onDelete: () -> Void
init(title: String, showDeleteButton: Bool, isDeleteLoading: Bool, onBack: @escaping () -> Void, onDelete: @escaping () -> Void) {
self.title = title
self.showDeleteButton = showDeleteButton
self.isDeleteLoading = isDeleteLoading
self.onBack = onBack
self.onDelete = onDelete
}
@SwiftUI.Environment(\.dismiss) private var dismiss: SwiftUI.DismissAction
var body: some View { var body: some View {
HStack { HStack {
// //
Button(action: onBack) { Button(action: {
onBack()
dismiss() // 使 dismiss
}) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 20, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
@@ -216,4 +225,4 @@ struct CustomNavigationBar: View {
// DetailFeature() // DetailFeature()
// } // }
// ) // )
//} //}

View File

@@ -207,7 +207,7 @@ private struct LoginContentView: View {
.keyboardType(.numberPad) .keyboardType(.numberPad)
Button(action: { Button(action: {
store.send(.getVerificationCodeButtonTapped) store.send(.getVerificationCodeTapped)
}) { }) {
Text(getCodeButtonText) Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
@@ -223,7 +223,7 @@ private struct LoginContentView: View {
// //
Button(action: { Button(action: {
store.send(.loginButtonTapped) store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
}) { }) {
if store.isLoading { if store.isLoading {
ProgressView() ProgressView()

View File

@@ -1,7 +1,6 @@
import SwiftUI import SwiftUI
import ComposableArchitecture import ComposableArchitecture
import PhotosUI import PhotosUI
//import ImagePreviewPager
struct EditFeedView: View { struct EditFeedView: View {
let onDismiss: () -> Void let onDismiss: () -> Void
@@ -46,35 +45,26 @@ struct EditFeedView: View {
.onAppear { .onAppear {
store.send(.clearError) store.send(.clearError)
} }
.onChange(of: store.shouldDismiss) { shouldDismiss in .onChange(of: store.shouldDismiss) {
if shouldDismiss { if store.shouldDismiss {
onDismiss() onDismiss()
} }
} }
.photosPicker( .photosPicker(
isPresented: store.binding( isPresented: Binding(
get: \.showPhotosPicker, get: { store.showPhotosPicker },
send: { _ in .photosPickerDismissed } set: { _ in store.send(.photosPickerDismissed) }
), ),
selection: store.binding( selection: Binding(
get: \.selectedPhotoItems, get: { store.selectedPhotoItems },
send: { .photosPickerItemsChanged($0) } set: { store.send(.photosPickerItemsChanged($0)) }
), ),
maxSelectionCount: 9, maxSelectionCount: 9,
matching: .images matching: .images
) )
.onChange(of: store.selectedPhotoItems) { items in .alert("删除图片", isPresented: Binding(
store.send(.photosPickerItemsChanged(items)) get: { store.showDeleteImageAlert },
} set: { _ in store.send(.deleteImageAlertDismissed) }
.onChange(of: store.selectedImages) { images in
//
}
.onChange(of: store.content) { content in
//
}
.alert("删除图片", isPresented: store.binding(
get: \.showDeleteImageAlert,
send: { _ in .deleteImageAlertDismissed }
)) { )) {
Button("删除", role: .destructive) { Button("删除", role: .destructive) {
if let indexToDelete = store.imageToDeleteIndex { if let indexToDelete = store.imageToDeleteIndex {
@@ -98,19 +88,12 @@ struct EditFeedView: View {
private func mainContent(geometry: GeometryProxy) -> some View { private func mainContent(geometry: GeometryProxy) -> some View {
WithPerceptionTracking { WithPerceptionTracking {
VStack(spacing: 0) { VStack(spacing: 0) {
//
topNavigationBar topNavigationBar
//
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
//
textInputSection textInputSection
//
imageSelectionSection imageSelectionSection
//
publishButton publishButton
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -141,7 +124,6 @@ struct EditFeedView: View {
Spacer() Spacer()
//
Color.clear Color.clear
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
} }
@@ -158,9 +140,9 @@ struct EditFeedView: View {
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
TextEditor(text: store.binding( TextEditor(text: Binding(
get: \.content, get: { store.content },
send: { .contentChanged($0) } set: { store.send(.contentChanged($0)) }
)) ))
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundColor(.white) .foregroundColor(.white)
@@ -191,66 +173,19 @@ struct EditFeedView: View {
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) { ImageGrid(
// images: store.processedImages,
ForEach(Array(store.selectedImages.enumerated()), id: \.offset) { index, image in onRemoveImage: { index in
imageItem(image: image, index: index) store.send(.showDeleteImageAlert(index))
},
onAddImage: {
store.send(.addImageButtonTapped)
} }
)
//
if store.selectedImages.count < 9 {
addImageButton
}
}
} }
} }
} }
private func imageItem(image: UIImage, index: Int) -> some View {
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 100)
.clipped()
.cornerRadius(8)
Button(action: {
store.send(.showDeleteImageAlert(index))
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.5))
.clipShape(Circle())
}
.padding(4)
}
}
private var addImageButton: some View {
Button(action: {
store.send(.addImageButtonTapped)
}) {
VStack {
Image(systemName: "plus")
.font(.system(size: 24))
.foregroundColor(.white.opacity(0.7))
Text("添加")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.7))
}
.frame(height: 100)
.frame(maxWidth: .infinity)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
private var publishButton: some View { private var publishButton: some View {
WithPerceptionTracking { WithPerceptionTracking {
Button(action: { Button(action: {
@@ -310,72 +245,78 @@ struct EditFeedView: View {
} }
} }
//#Preview { // MARK: -
// EditFeedView() struct ImageGrid: View {
//}
// MARK: -
struct ModernImageSelectionGrid: View {
let images: [UIImage] let images: [UIImage]
let selectedItems: [PhotosPickerItem]
let canAddMore: Bool
let onItemsChanged: ([PhotosPickerItem]) -> Void
let onRemoveImage: (Int) -> Void let onRemoveImage: (Int) -> Void
let onAddImage: () -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3) private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
@State private var showPreview = false
@State private var previewIndex = 0
var body: some View { var body: some View {
let totalSpacing: CGFloat = 8 * 2
let totalWidth = UIScreen.main.bounds.width - 24 * 2 - totalSpacing
let gridItemSize: CGFloat = totalWidth / 3
LazyVGrid(columns: columns, spacing: 8) { LazyVGrid(columns: columns, spacing: 8) {
ForEach(Array(images.enumerated()), id: \.offset) { index, image in ForEach(Array(images.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) { ImageGridItem(
Image(uiImage: image) image: image,
.resizable() onRemove: { onRemoveImage(index) }
.aspectRatio(contentMode: .fill) // aspectFill )
.frame(width: gridItemSize, height: gridItemSize)
.clipped()
.cornerRadius(12)
.onTapGesture {
previewIndex = index
showPreview = true
}
Button(action: {
onRemoveImage(index)
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
}
.padding(4)
}
} }
if canAddMore {
PhotosPicker( if images.count < 9 {
selection: .init( AddImageButton(onTap: onAddImage)
get: { selectedItems },
set: { items in DispatchQueue.main.async { onItemsChanged(items) } }
),
maxSelectionCount: 9 - images.count,
matching: .images
) {
RoundedRectangle(cornerRadius: 12)
.fill(Color(hexString: "1C143A"))
.frame(width: gridItemSize, height: gridItemSize)
.overlay(
Image("add photo")
.resizable()
.frame(width: 40, height: 40)
.opacity(0.6)
)
}
} }
} }
.fullScreenCover(isPresented: $showPreview) { }
ImagePreviewPager(images: images, currentIndex: $previewIndex, onClose: { showPreview = false }) }
}
// MARK: -
struct ImageGridItem: View {
let image: UIImage
let onRemove: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 100)
.clipped()
.cornerRadius(8)
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white)
.background(Color.black.opacity(0.5))
.clipShape(Circle())
}
.padding(4)
}
}
}
// MARK: -
struct AddImageButton: View {
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack {
Image(systemName: "plus")
.font(.system(size: 24))
.foregroundColor(.white.opacity(0.7))
Text("添加")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.7))
}
.frame(height: 100)
.frame(maxWidth: .infinity)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
} }
} }

View File

@@ -172,37 +172,35 @@ struct FeedListContentView: View {
@Binding var previewCurrentIndex: Int @Binding var previewCurrentIndex: Int
var body: some View { var body: some View {
WithPerceptionTracking { if store.isLoading {
if store.isLoading { FeedListLoadingView()
LoadingView() } else if let error = store.error {
} else if let error = store.error { ErrorView(error: error)
ErrorView(error: error) } else if store.moments.isEmpty {
} else if store.moments.isEmpty { EmptyView()
EmptyView() } else {
} else { MomentsListView(
MomentsListView( moments: store.moments,
moments: store.moments, hasMore: store.hasMore,
hasMore: store.hasMore, isLoadingMore: store.isLoadingMore,
isLoadingMore: store.isLoadingMore, onImageTap: { images, tappedIndex in
onImageTap: { images, tappedIndex in previewCurrentIndex = tappedIndex
previewCurrentIndex = tappedIndex previewItem = PreviewItem(images: images, index: tappedIndex)
previewItem = PreviewItem(images: images, index: tappedIndex) },
}, onMomentTap: { moment in
onMomentTap: { moment in store.send(.showDetail(moment))
store.send(.showDetail(moment)) },
}, onLikeTap: { dynamicId, uid, likedUid, worldId in
onLikeTap: { dynamicId, uid, likedUid, worldId in store.send(.likeDynamic(dynamicId, uid, likedUid, worldId))
store.send(.likeDynamic(dynamicId, uid, likedUid, worldId)) },
}, onLoadMore: {
onLoadMore: { store.send(.loadMore)
store.send(.loadMore) },
}, onRefresh: {
onRefresh: { store.send(.reload)
store.send(.reload) },
}, likeLoadingDynamicIds: store.likeLoadingDynamicIds
likeLoadingDynamicIds: store.likeLoadingDynamicIds )
)
}
} }
} }
} }
@@ -214,7 +212,7 @@ struct FeedListView: View {
@State private var previewCurrentIndex: Int = 0 @State private var previewCurrentIndex: Int = 0
var body: some View { var body: some View {
WithPerceptionTracking { WithViewStore(store, observe: { $0 }) { viewStore in
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
// //
@@ -252,41 +250,30 @@ struct FeedListView: View {
.onAppear { .onAppear {
store.send(.onAppear) store.send(.onAppear)
} }
.onRefresh { .refreshable {
store.send(.reload) store.send(.reload)
} }
// //
.sheet(isPresented: store.binding( .sheet(isPresented: viewStore.binding(get: \.isEditFeedPresented, send: { _ in .editFeedDismissed })) {
get: \.showEditFeed, EditFeedView(
send: { _ in .editFeedDismissed } onDismiss: {
)) { store.send(.editFeedDismissed)
WithPerceptionTracking { },
EditFeedView( store: Store(
onDismiss: { initialState: EditFeedFeature.State()
store.send(.editFeedDismissed) ) {
}, EditFeedFeature()
store: Store( }
initialState: EditFeedFeature.State() )
) {
EditFeedFeature()
}
)
}
} }
// //
.navigationDestination(isPresented: store.binding( .navigationDestination(isPresented: viewStore.binding(get: \.showDetail, send: { _ in .detailDismissed })) {
get: \.showDetail, if let selectedMoment = viewStore.selectedMoment {
send: { _ in .detailDismissed }
)) {
if let selectedMoment = store.selectedMoment {
DetailView( DetailView(
store: Store( store: Store(
initialState: DetailFeature.State(moment: selectedMoment) initialState: DetailFeature.State(moment: selectedMoment)
) { ) {
DetailFeature() DetailFeature()
},
onDismiss: {
store.send(.detailDismissed)
} }
) )
} }

View File

@@ -2,17 +2,174 @@ import SwiftUI
import ComposableArchitecture import ComposableArchitecture
import Perception import Perception
// MARK: -
struct IDLoginBackgroundView: View {
var body: some View {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
}
}
// MARK: -
struct IDLoginHeaderView: View {
let onBack: () -> Void
var body: some View {
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
}
// MARK: -
struct IDLoginInputFieldView: View {
let iconName: String
let title: String
let text: Binding<String>
let onChange: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
Text(title)
.font(.system(size: 16))
.foregroundColor(.white)
}
TextField("", text: text)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 16))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.onChange(of: text.wrappedValue) { newValue in
onChange(newValue)
}
}
}
}
// MARK: -
struct IDLoginPasswordFieldView: View {
let password: Binding<String>
let isPasswordVisible: Binding<Bool>
let onChange: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image("email icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
Text(LocalizedString("id_login.password", comment: ""))
.font(.system(size: 16))
.foregroundColor(.white)
}
HStack {
Group {
if isPasswordVisible.wrappedValue {
TextField("", text: password)
.textFieldStyle(PlainTextFieldStyle())
} else {
SecureField("", text: password)
.textFieldStyle(PlainTextFieldStyle())
}
}
Button(action: {
isPasswordVisible.wrappedValue.toggle()
}) {
Image(systemName: isPasswordVisible.wrappedValue ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
}
}
.font(.system(size: 16))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.onChange(of: password.wrappedValue) { newValue in
onChange(newValue)
}
}
}
}
// MARK: -
struct IDLoginButtonView: View {
let isLoading: Bool
let isEnabled: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
Group {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
} else {
Text(LocalizedString("id_login.login", comment: ""))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(isEnabled ? Color.blue : Color.gray)
.cornerRadius(8)
.disabled(!isEnabled)
.padding(.top, 20)
}
}
// MARK: -
struct IDLoginErrorView: View {
let errorMessage: String?
var body: some View {
if let errorMessage = errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
}
}
// MARK: -
struct IDLoginView: View { struct IDLoginView: View {
let store: StoreOf<IDLoginFeature> let store: StoreOf<IDLoginFeature>
let onBack: () -> Void let onBack: () -> Void
@Binding var showIDLogin: Bool // @Binding var showIDLogin: Bool
// 使@StateUI // 使@StateUI
@State private var userID: String = "" @State private var userID: String = ""
@State private var password: String = "" @State private var password: String = ""
@State private var isPasswordVisible: Bool = false @State private var isPasswordVisible: Bool = false
// - LoginView //
@State private var showRecoverPassword: Bool = false @State private var showRecoverPassword: Bool = false
// //
@@ -24,28 +181,12 @@ struct IDLoginView: View {
WithPerceptionTracking { WithPerceptionTracking {
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
// - 使"bg" //
Image("bg") IDLoginBackgroundView()
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) { VStack(spacing: 0) {
// //
HStack { IDLoginHeaderView(onBack: onBack)
Button(action: {
onBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer() Spacer()
.frame(height: 60) .frame(height: 60)
@@ -59,68 +200,23 @@ struct IDLoginView: View {
// //
VStack(spacing: 20) { VStack(spacing: 20) {
// ID // ID
VStack(alignment: .leading, spacing: 8) { IDLoginInputFieldView(
HStack { iconName: "id icon",
Image("id icon") title: LocalizedString("id_login.user_id", comment: ""),
.resizable() text: $userID,
.aspectRatio(contentMode: .fit) onChange: { newValue in
.frame(width: 20, height: 20) store.send(.userIDChanged(newValue))
Text(LocalizedString("id_login.user_id", comment: ""))
.font(.system(size: 16))
.foregroundColor(.white)
} }
)
TextField("", text: $userID)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 16))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.onChange(of: userID) { newValue in
store.send(.userIDChanged(newValue))
}
}
// //
VStack(alignment: .leading, spacing: 8) { IDLoginPasswordFieldView(
HStack { password: $password,
Image("email icon") isPasswordVisible: $isPasswordVisible,
.resizable() onChange: { newValue in
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
Text(LocalizedString("id_login.password", comment: ""))
.font(.system(size: 16))
.foregroundColor(.white)
}
HStack {
if isPasswordVisible {
TextField("", text: $password)
.textFieldStyle(PlainTextFieldStyle())
} else {
SecureField("", text: $password)
.textFieldStyle(PlainTextFieldStyle())
}
Button(action: {
isPasswordVisible.toggle()
}) {
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
}
}
.font(.system(size: 16))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
.onChange(of: password) { newValue in
store.send(.passwordChanged(newValue)) store.send(.passwordChanged(newValue))
} }
} )
// //
HStack { HStack {
@@ -135,45 +231,26 @@ struct IDLoginView: View {
} }
// //
Button(action: { IDLoginButtonView(
store.send(.loginButtonTapped) isLoading: store.isLoading,
}) { isEnabled: isLoginButtonEnabled,
if store.isLoading { onTap: {
ProgressView() store.send(.loginButtonTapped(userID: userID, password: password))
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
} else {
Text(LocalizedString("id_login.login", comment: ""))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
} }
} )
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(8)
.disabled(!isLoginButtonEnabled)
.padding(.top, 20)
// //
if let errorMessage = store.errorMessage { IDLoginErrorView(errorMessage: store.errorMessage)
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer() Spacer()
} }
// API Loading // API Loading
APILoadingEffectView() APILoadingEffectView()
} }
} }
} }
.navigationBarHidden(true) .navigationBarHidden(true)
// 使 LoginView navigationDestination
.navigationDestination(isPresented: $showRecoverPassword) { .navigationDestination(isPresented: $showRecoverPassword) {
WithPerceptionTracking { WithPerceptionTracking {
RecoverPasswordView( RecoverPasswordView(
@@ -197,7 +274,6 @@ struct IDLoginView: View {
isPasswordVisible = store.isPasswordVisible isPasswordVisible = store.isPasswordVisible
#if DEBUG #if DEBUG
//
debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据") debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
#endif #endif
} }

View File

@@ -12,7 +12,7 @@ struct ImageHeightPreferenceKey: PreferenceKey {
struct LoginView: View { struct LoginView: View {
let store: StoreOf<LoginFeature> let store: StoreOf<LoginFeature>
let onLoginSuccess: () -> Void // let onLoginSuccess: () -> Void
// 使@StateUI // 使@StateUI
@State private var showIDLogin: Bool = false @State private var showIDLogin: Bool = false
@@ -21,136 +21,44 @@ struct LoginView: View {
@State private var showUserAgreement: Bool = false @State private var showUserAgreement: Bool = false
@State private var showPrivacyPolicy: Bool = false @State private var showPrivacyPolicy: Bool = false
//
private var topImageHeight: CGFloat = 200 //
var body: some View { var body: some View {
WithPerceptionTracking { NavigationStack {
NavigationStack { GeometryReader { geometry in
GeometryReader { geometry in ZStack {
ZStack { backgroundView
// 使 splash mainContentView(geometry: geometry)
Image("bg") APILoadingEffectView()
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
// "top"
ZStack {
Image("top")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.padding(.top, -100)
.background(
GeometryReader { topImageGeometry in
Color.clear.preference(key: ImageHeightPreferenceKey.self, value: topImageGeometry.size.height)
}
)
// E-PARTI "top"20
HStack {
Text(LocalizedString("login.app_title", comment: ""))
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
.foregroundColor(.white)
.padding(.leading, 20)
Spacer()
}
.padding(.top, max(0, topImageHeight - 100)) // top - 140
}
//
VStack(spacing: 20) {
//
LoginButton(
title: LocalizedString("login.id_login", comment: ""),
icon: "person.circle",
action: {
showIDLogin = true
}
)
LoginButton(
title: LocalizedString("login.email_login", comment: ""),
icon: "envelope",
action: {
showEmailLogin = true
}
)
//
HStack(spacing: 20) {
Button(action: {
showLanguageSettings = true
}) {
Text(LocalizedString("login.language", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showUserAgreement = true
}) {
Text(LocalizedString("login.user_agreement", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showPrivacyPolicy = true
}) {
Text(LocalizedString("login.privacy_policy", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
}
}
.padding(.horizontal, 28)
.padding(.bottom, 140)
}
// API Loading
APILoadingEffectView()
// NavigationLink navigationDestination
}
}
.navigationBarHidden(true)
// iOS 16 navigationDestination
.navigationDestination(isPresented: $showIDLogin) {
WithPerceptionTracking {
IDLoginView(
store: store.scope(
state: \.idLoginState,
action: \.idLogin
),
onBack: {
showIDLogin = false
},
showIDLogin: $showIDLogin // Binding
)
.navigationBarHidden(true)
}
}
.navigationDestination(isPresented: $showEmailLogin) {
WithPerceptionTracking {
EMailLoginView(
store: store.scope(
state: \.emailLoginState,
action: \.emailLogin
),
onBack: {
showEmailLogin = false
},
showEmailLogin: $showEmailLogin // Binding
)
.navigationBarHidden(true)
}
} }
} }
.navigationBarHidden(true)
.navigationDestination(isPresented: $showIDLogin) {
IDLoginView(
store: store.scope(
state: \.idLoginState,
action: \.idLogin
),
onBack: {
showIDLogin = false
},
showIDLogin: $showIDLogin
)
.navigationBarHidden(true)
}
.navigationDestination(isPresented: $showEmailLogin) {
EMailLoginView(
store: store.scope(
state: \.emailLoginState,
action: \.emailLogin
),
onBack: {
showEmailLogin = false
},
showEmailLogin: $showEmailLogin
)
.navigationBarHidden(true)
}
.sheet(isPresented: $showLanguageSettings) { .sheet(isPresented: $showLanguageSettings) {
WithPerceptionTracking { LanguageSettingsView(isPresented: $showLanguageSettings)
LanguageSettingsView(isPresented: $showLanguageSettings)
}
} }
.webView( .webView(
isPresented: $showUserAgreement, isPresented: $showUserAgreement,
@@ -160,26 +68,122 @@ struct LoginView: View {
isPresented: $showPrivacyPolicy, isPresented: $showPrivacyPolicy,
url: APIConfiguration.webURL(for: .privacyPolicy) url: APIConfiguration.webURL(for: .privacyPolicy)
) )
// .onChange(of: store.isAnyLoginCompleted) {
.onChange(of: store.isAnyLoginCompleted) { completed in if store.isAnyLoginCompleted {
if completed {
onLoginSuccess() onLoginSuccess()
} }
} }
// showIDLogin .onChange(of: showIDLogin) {
.onChange(of: showIDLogin) { newValue in if showIDLogin == false && store.isAnyLoginCompleted {
if newValue == false && store.isAnyLoginCompleted {
onLoginSuccess() onLoginSuccess()
} }
} }
// showEmailLogin .onChange(of: showEmailLogin) {
.onChange(of: showEmailLogin) { newValue in if showEmailLogin == false && store.isAnyLoginCompleted {
if newValue == false && store.isAnyLoginCompleted {
onLoginSuccess() onLoginSuccess()
} }
} }
} }
} }
// MARK: -
private var backgroundView: some View {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
}
private func mainContentView(geometry: GeometryProxy) -> some View {
VStack(spacing: 0) {
topSection(geometry: geometry)
bottomSection
}
}
private func topSection(geometry: GeometryProxy) -> some View {
ZStack {
Image("top")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.padding(.top, -100)
.background(
GeometryReader { topImageGeometry in
Color.clear.preference(key: ImageHeightPreferenceKey.self, value: topImageGeometry.size.height)
}
)
HStack {
Text(LocalizedString("login.app_title", comment: ""))
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
.foregroundColor(.white)
.padding(.leading, 20)
Spacer()
}
.padding(.top, 100) //
}
}
private var bottomSection: some View {
VStack(spacing: 20) {
loginButtons
bottomButtons
}
.padding(.horizontal, 28)
.padding(.bottom, 140)
}
private var loginButtons: some View {
VStack(spacing: 20) {
LoginButton(
iconName: "person.circle",
iconColor: .blue,
title: LocalizedString("login.id_login", comment: ""),
action: {
showIDLogin = true
}
)
LoginButton(
iconName: "envelope",
iconColor: .green,
title: LocalizedString("login.email_login", comment: ""),
action: {
showEmailLogin = true
}
)
}
}
private var bottomButtons: some View {
HStack(spacing: 20) {
Button(action: {
showLanguageSettings = true
}) {
Text(LocalizedString("login.language", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showUserAgreement = true
}) {
Text(LocalizedString("login.user_agreement", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showPrivacyPolicy = true
}) {
Text(LocalizedString("login.privacy_policy", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
}
}
} }
//#Preview { //#Preview {

View File

@@ -8,8 +8,8 @@ struct MainView: View {
var body: some View { var body: some View {
WithPerceptionTracking { WithPerceptionTracking {
InternalMainView(store: store) InternalMainView(store: store)
.onChange(of: store.isLoggedOut) { isLoggedOut in .onChange(of: store.isLoggedOut) {
if isLoggedOut { if store.isLoggedOut {
onLogout?() onLogout?()
} }
} }
@@ -32,12 +32,12 @@ struct InternalMainView: View {
.navigationDestination(for: MainFeature.Destination.self) { destination in .navigationDestination(for: MainFeature.Destination.self) { destination in
DestinationView(destination: destination, store: self.store) DestinationView(destination: destination, store: self.store)
} }
.onChange(of: path) { newPath in .onChange(of: path) {
store.send(.navigationPathChanged(newPath)) store.send(.navigationPathChanged(path))
} }
.onChange(of: store.navigationPath) { newPath in .onChange(of: store.navigationPath) {
if path != newPath { if path != store.navigationPath {
path = newPath path = store.navigationPath
} }
} }
.onAppear { .onAppear {
@@ -91,9 +91,11 @@ struct InternalMainView: View {
// - // -
VStack { VStack {
Spacer() Spacer()
BottomTabView(selectedTab: store.binding( BottomTabView(selectedTab: Binding(
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed }, get: { Tab(rawValue: store.selectedTab.rawValue) ?? .feed },
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) } set: { newTab in
store.send(.selectTab(MainFeature.Tab(rawValue: newTab.rawValue) ?? .feed))
}
)) ))
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)

View File

@@ -60,21 +60,23 @@ struct MeView: View {
} }
} }
// //
.navigationDestination(isPresented: store.binding( .navigationDestination(isPresented: Binding(
get: \.showDetail, get: { store.showDetail },
send: { _ in .detailDismissed } set: { _ in store.send(.detailDismissed) }
)) { )) {
if let selectedMoment = store.selectedMoment { if let selectedMoment = store.selectedMoment {
DetailView( let detailStore = Store(
store: Store( initialState: DetailFeature.State(moment: selectedMoment)
initialState: DetailFeature.State(moment: selectedMoment) ) {
) { DetailFeature()
DetailFeature() }
},
onDismiss: { DetailView(store: detailStore)
store.send(.detailDismissed) .onChange(of: detailStore.shouldDismiss) { shouldDismiss in
if shouldDismiss {
store.send(.detailDismissed)
}
} }
)
} }
} }
} }
@@ -121,7 +123,7 @@ struct MeView: View {
@ViewBuilder @ViewBuilder
private func momentsSection() -> some View { private func momentsSection() -> some View {
WithPerceptionTracking { WithPerceptionTracking {
if store.isLoading { if store.isLoadingUserInfo || store.isLoadingMoments {
VStack { VStack {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .progressViewStyle(CircularProgressViewStyle(tint: .white))
@@ -132,7 +134,7 @@ struct MeView: View {
.padding(.top, 8) .padding(.top, 8)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = store.error { } else if let error = store.userInfoError ?? store.momentsError {
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32)) .font(.system(size: 32))

View File

@@ -5,50 +5,48 @@ struct SplashView: View {
let store: StoreOf<SplashFeature> let store: StoreOf<SplashFeature>
var body: some View { var body: some View {
WithPerceptionTracking { ZStack {
ZStack { Group {
Group { //
// if let navigationDestination = store.navigationDestination {
if let navigationDestination = store.navigationDestination { switch navigationDestination {
switch navigationDestination { case .login:
case .login: //
// LoginView(
LoginView( store: Store(
store: Store( initialState: LoginFeature.State()
initialState: LoginFeature.State() ) {
) { LoginFeature()
LoginFeature() },
}, onLoginSuccess: {
onLoginSuccess: { //
// store.send(.navigateToMain)
store.send(.navigateToMain) }
} )
) case .main:
case .main: //
// MainView(
MainView( store: Store(
store: Store( initialState: MainFeature.State()
initialState: MainFeature.State() ) {
) { MainFeature()
MainFeature() },
}, onLogout: {
onLogout: { store.send(.navigateToLogin)
store.send(.navigateToLogin) }
} )
)
}
} else {
//
splashContent
} }
} else {
//
splashContent
} }
.onAppear {
store.send(.onAppear)
}
// API Loading -
APILoadingEffectView()
} }
.onAppear {
store.send(.onAppear)
}
// API Loading -
APILoadingEffectView()
} }
} }

View File

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