diff --git a/CreateFeedView-Analysis.md b/CreateFeedView-Analysis.md new file mode 100644 index 0000000..2176e77 --- /dev/null +++ b/CreateFeedView-Analysis.md @@ -0,0 +1,79 @@ +# CreateFeedView UI 结构分析与执行计划 + +## UI 结构分析 + +根据设计稿,CreateFeedView 应包含以下UI元素: + +### 1. 顶部导航栏 +- 左侧:返回按钮 +- 中间:"图文发布" 标题 +- 右侧:"发布" 按钮 + +### 2. 主要内容区域 +- 文本输入框:"Enter Content" 占位符,支持多行输入,最大500字符 +- 字符计数显示:"0/500" 格式 +- 图片添加区域: + - 默认显示一个 "+" 按钮(使用 "add photo" 图片资源) + - 支持添加最多9张图片 + - 图片以网格形式排列 + - 每张图片可以删除 + +### 3. 底部发布按钮 +- 紫色渐变背景的"发布"按钮 +- 占据屏幕底部,固定位置 + +## 执行计划 + +### 第一步:创建 CreateFeedFeature +- 定义状态管理结构 +- 实现文本输入、图片选择、发布等Action +- 添加表单验证逻辑 +- 集成图片选择器 + +### 第二步:创建 CreateFeedView +- 实现顶部导航栏 +- 创建文本输入区域 +- 实现图片选择和展示网格 +- 添加发布按钮 +- 应用深色主题样式 + +### 第三步:集成到 FeedView +- 修改 FeedView 中的加号按钮点击事件 +- 添加导航到 CreateFeedView 的逻辑 +- 确保返回时能刷新动态列表 + +### 第四步:创建发布API模型 +- 定义发布动态的请求和响应模型 +- 添加API端点定义 +- 实现发布逻辑(模拟或真实API) + +### 第五步:测试和优化 +- 测试各种输入场景 +- 验证图片选择和预览功能 +- 确保UI响应和交互流畅 + +## 技术要点 + +1. **状态管理**:使用 ComposableArchitecture 模式 +2. **图片选择**:使用 PhotosUI 框架 +3. **UI样式**:保持与现有深色主题一致 +4. **表单验证**:实时字符计数和输入限制 +5. **导航管理**:使用 NavigationStack 或 sheet 展示 + +## 文件结构 + +``` +yana/ +├── Features/ +│ └── CreateFeedFeature.swift # 新建 +├── Views/ +│ └── CreateFeedView.swift # 新建 +├── APIs/ +│ ├── APIEndpoints.swift # 修改:添加发布端点 +│ └── DynamicsModels.swift # 修改:添加发布模型 +└── Assets.xcassets/ + └── Home/ + └── add photo.imageset/ # 已存在 +``` + +开始实施第一步:创建 CreateFeedFeature。 \ No newline at end of file diff --git a/yana/Features/CreateFeedFeature.swift b/yana/Features/CreateFeedFeature.swift new file mode 100644 index 0000000..bb67308 --- /dev/null +++ b/yana/Features/CreateFeedFeature.swift @@ -0,0 +1,223 @@ +import Foundation +import ComposableArchitecture +import SwiftUI + +// 条件导入 PhotosUI (iOS 16.0+) +#if canImport(PhotosUI) +import PhotosUI +#endif + +@Reducer +struct CreateFeedFeature { + @ObservableState + struct State: Equatable { + var content: String = "" + var processedImages: [UIImage] = [] + var isLoading: Bool = false + var errorMessage: String? = nil + var characterCount: Int = 0 + + // iOS 16+ PhotosPicker 支持 + #if canImport(PhotosUI) && swift(>=5.7) + var selectedImages: [PhotosPickerItem] = [] + #endif + + // iOS 15 UIImagePickerController 支持 + var showingImagePicker: Bool = false + + + var canAddMoreImages: Bool { + processedImages.count < 9 + } + + var canPublish: Bool { + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading + } + } + + enum Action { + case contentChanged(String) + case publishButtonTapped + case publishResponse(Result) + case clearError + case dismissView + + // iOS 16+ PhotosPicker Actions + #if canImport(PhotosUI) && swift(>=5.7) + case photosPickerItemsChanged([PhotosPickerItem]) + case processPhotosPickerItems([PhotosPickerItem]) + #endif + + // iOS 15 UIImagePickerController Actions + case showImagePicker + case hideImagePicker + case imageSelected(UIImage) + + case removeImage(Int) + } + + @Dependency(\.apiService) var apiService + @Dependency(\.dismiss) var dismiss + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .contentChanged(let newContent): + state.content = newContent + state.characterCount = newContent.count + return .none + + #if canImport(PhotosUI) && swift(>=5.7) + case .photosPickerItemsChanged(let items): + state.selectedImages = items + return .run { send in + await send(.processPhotosPickerItems(items)) + } + + case .processPhotosPickerItems(let items): + return .run { [currentImages = state.processedImages] send in + var newImages = currentImages + + for item in items { + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + if newImages.count < 9 { + newImages.append(image) + } + } + } + + await MainActor.run { + state.processedImages = newImages + } + } + #endif + + case .showImagePicker: + state.showingImagePicker = true + return .none + + case .hideImagePicker: + state.showingImagePicker = false + return .none + + case .imageSelected(let image): + if state.processedImages.count < 9 { + state.processedImages.append(image) + } + state.showingImagePicker = false + return .none + + case .removeImage(let index): + guard index < state.processedImages.count else { return .none } + state.processedImages.remove(at: index) + #if canImport(PhotosUI) && swift(>=5.7) + if index < state.selectedImages.count { + state.selectedImages.remove(at: index) + } + #endif + return .none + + case .publishButtonTapped: + guard state.canPublish else { + state.errorMessage = "请输入内容" + return .none + } + + state.isLoading = true + state.errorMessage = nil + + let request = PublishDynamicRequest( + content: state.content.trimmingCharacters(in: .whitespacesAndNewlines), + images: state.processedImages + ) + + return .run { send in + do { + let response = try await apiService.request(request) + await send(.publishResponse(.success(response))) + } catch { + await send(.publishResponse(.failure(error))) + } + } + + case .publishResponse(.success(let response)): + state.isLoading = false + + if response.code == 200 { + // 发布成功,关闭页面 + return .send(.dismissView) + } else { + state.errorMessage = response.message.isEmpty ? "发布失败" : response.message + return .none + } + + case .publishResponse(.failure(let error)): + state.isLoading = false + state.errorMessage = error.localizedDescription + return .none + + case .clearError: + state.errorMessage = nil + return .none + + case .dismissView: + return .run { _ in + await dismiss() + } + } + } + } +} + +// MARK: - 发布动态相关模型 + +/// 发布动态请求 +struct PublishDynamicRequest: APIRequestProtocol { + typealias Response = PublishDynamicResponse + + let endpoint: String = "/dynamic/square/publish" // 假设的发布端点 + let method: HTTPMethod = .POST + let includeBaseParameters: Bool = true + let queryParameters: [String: String]? = nil + let timeout: TimeInterval = 30.0 + + let content: String + let images: [UIImage] + let type: Int // 0: 纯文字, 2: 图片 + + init(content: String, images: [UIImage] = []) { + self.content = content + self.images = images + self.type = images.isEmpty ? 0 : 2 + } + + var bodyParameters: [String: Any]? { + var params: [String: Any] = [ + "content": content, + "type": type + ] + + // 如果有图片,需要转换为base64或上传到服务器 + if !images.isEmpty { + let imageData = images.compactMap { image in + image.jpegData(compressionQuality: 0.8)?.base64EncodedString() + } + params["images"] = imageData + } + + return params + } +} + +/// 发布动态响应 +struct PublishDynamicResponse: Codable { + let code: Int + let message: String + let data: PublishDynamicData? +} + +struct PublishDynamicData: Codable { + let dynamicId: Int + let publishTime: Int +} \ No newline at end of file diff --git a/yana/Features/FeedFeature.swift b/yana/Features/FeedFeature.swift index a7f8f41..847c7c8 100644 --- a/yana/Features/FeedFeature.swift +++ b/yana/Features/FeedFeature.swift @@ -13,6 +13,9 @@ struct FeedFeature { // 是否已初始化 var isInitialized = false + + // CreateFeedView 相关状态 + var isShowingCreateFeed = false } enum Action: Equatable { @@ -22,6 +25,11 @@ struct FeedFeature { case momentsResponse(TaskResult) case clearError case retryLoad + + // CreateFeedView 相关 Action + case showCreateFeed + case dismissCreateFeed + case createFeedCompleted } @Dependency(\.apiService) var apiService @@ -131,7 +139,20 @@ struct FeedFeature { } else { return .send(.loadMoreMoments) } + + case .showCreateFeed: + state.isShowingCreateFeed = true + return .none + + case .dismissCreateFeed: + state.isShowingCreateFeed = false + return .none + + case .createFeedCompleted: + state.isShowingCreateFeed = false + // 发布完成后刷新动态列表 + return .send(.loadLatestMoments) } } } -} +} diff --git a/yana/Views/CreateFeedView.swift b/yana/Views/CreateFeedView.swift new file mode 100644 index 0000000..ab7cc6a --- /dev/null +++ b/yana/Views/CreateFeedView.swift @@ -0,0 +1,364 @@ +import SwiftUI +import ComposableArchitecture + +// 条件导入 PhotosUI (iOS 16.0+) +#if canImport(PhotosUI) +import PhotosUI +#endif + +struct CreateFeedView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + NavigationView { + GeometryReader { geometry in + ZStack { + // 背景渐变 + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.1, green: 0.1, blue: 0.2), + Color(red: 0.2, green: 0.1, blue: 0.3) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + // 内容输入区域 + VStack(alignment: .leading, spacing: 12) { + // 文本输入框 + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.1)) + .frame(minHeight: 120) + + if store.content.isEmpty { + Text("Enter Content") + .foregroundColor(.white.opacity(0.5)) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + TextEditor(text: .init( + get: { store.content }, + set: { store.send(.contentChanged($0)) } + )) + .foregroundColor(.white) + .background(Color.clear) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .scrollContentBackground(.hidden) + } + + // 字符计数 + HStack { + Spacer() + Text("\(store.characterCount)/500") + .font(.system(size: 12)) + .foregroundColor( + store.isCharacterLimitExceeded ? .red : .white.opacity(0.6) + ) + } + } + .padding(.horizontal, 20) + .padding(.top, 20) + + // 图片选择区域 + VStack(alignment: .leading, spacing: 12) { + if !store.processedImages.isEmpty || store.canAddMoreImages { + if #available(iOS 16.0, *) { + #if canImport(PhotosUI) + ModernImageSelectionGrid( + images: store.processedImages, + selectedItems: store.selectedImages, + canAddMore: store.canAddMoreImages, + onItemsChanged: { items in + store.send(.photosPickerItemsChanged(items)) + }, + onRemoveImage: { index in + store.send(.removeImage(index)) + } + ) + #endif + } else { + LegacyImageSelectionGrid( + images: store.processedImages, + canAddMore: store.canAddMoreImages, + onAddImage: { + store.send(.showImagePicker) + }, + onRemoveImage: { index in + store.send(.removeImage(index)) + } + ) + } + } + } + .padding(.horizontal, 20) + + // 加载状态 + if store.isLoading { + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + Text("处理图片中...") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.8)) + } + .padding(.top, 10) + } + + // 错误提示 + if let error = store.error { + Text(error) + .font(.system(size: 14)) + .foregroundColor(.red) + .padding(.horizontal, 20) + .multilineTextAlignment(.center) + } + + // 底部安全区域 + Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100) + } + } + + // 底部发布按钮 + VStack { + Spacer() + + Button(action: { + store.send(.publishButtonTapped) + }) { + HStack { + if store.isPublishing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + Text("发布中...") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + } else { + Text("发布") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + LinearGradient( + gradient: Gradient(colors: [ + Color.purple, + Color.blue + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(25) + .disabled(store.isPublishing || (!store.isContentValid && !store.isLoading)) + .opacity(store.isPublishing || (!store.isContentValid && !store.isLoading) ? 0.6 : 1.0) + } + .padding(.horizontal, 20) + .padding(.bottom, geometry.safeAreaInsets.bottom + 20) + } + } + } + .navigationTitle("图文发布") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("取消") { + store.send(.dismissView) + } + .foregroundColor(.white) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("发布") { + store.send(.publishButtonTapped) + } + .foregroundColor(store.isContentValid ? .white : .white.opacity(0.5)) + .disabled(!store.isContentValid || store.isPublishing) + } + } + } + .preferredColorScheme(.dark) + .sheet(isPresented: .init( + get: { store.showingImagePicker }, + set: { _ in store.send(.hideImagePicker) } + )) { + ImagePickerView { image in + store.send(.imageSelected(image)) + } + } + } + } +} + +// MARK: - iOS 16+ 图片选择网格组件 +#if canImport(PhotosUI) +@available(iOS 16.0, *) +struct ModernImageSelectionGrid: View { + let images: [UIImage] + let selectedItems: [PhotosPickerItem] + let canAddMore: Bool + let onItemsChanged: ([PhotosPickerItem]) -> Void + let onRemoveImage: (Int) -> Void + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3) + + var body: some View { + LazyVGrid(columns: columns, spacing: 8) { + // 显示已选择的图片 + ForEach(Array(images.enumerated()), id: \.offset) { index, image in + ZStack(alignment: .topTrailing) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 100) + .clipped() + .cornerRadius(8) + + // 删除按钮 + Button(action: { + onRemoveImage(index) + }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.white) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + } + .padding(4) + } + } + + // 添加图片按钮 + if canAddMore { + PhotosPicker( + selection: .init( + get: { selectedItems }, + set: onItemsChanged + ), + maxSelectionCount: 9, + matching: .images + ) { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.opacity(0.1)) + .frame(height: 100) + .overlay( + Image(systemName: "plus") + .font(.system(size: 40)) + .foregroundColor(.white.opacity(0.6)) + ) + } + } + } + } +} +#endif + +// MARK: - iOS 15 兼容图片选择网格组件 +struct LegacyImageSelectionGrid: View { + let images: [UIImage] + let canAddMore: Bool + let onAddImage: () -> Void + let onRemoveImage: (Int) -> Void + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3) + + var body: some View { + LazyVGrid(columns: columns, spacing: 8) { + // 显示已选择的图片 + ForEach(Array(images.enumerated()), id: \.offset) { index, image in + ZStack(alignment: .topTrailing) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 100) + .clipped() + .cornerRadius(8) + + // 删除按钮 + 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 { + Button(action: onAddImage) { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.opacity(0.1)) + .frame(height: 100) + .overlay( + Image(systemName: "plus") + .font(.system(size: 40)) + .foregroundColor(.white.opacity(0.6)) + ) + } + } + } + } +} + +// MARK: - UIImagePicker 包装器 +struct ImagePickerView: UIViewControllerRepresentable { + let onImageSelected: (UIImage) -> Void + @Environment(\.presentationMode) var presentationMode + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .photoLibrary + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePickerView + + init(_ parent: ImagePickerView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let image = info[.originalImage] as? UIImage { + parent.onImageSelected(image) + } + parent.presentationMode.wrappedValue.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.presentationMode.wrappedValue.dismiss() + } + } +} + +// MARK: - 预览 +#Preview { + CreateFeedView( + store: Store(initialState: CreateFeedFeature.State()) { + CreateFeedFeature() + } + ) +} \ No newline at end of file diff --git a/yana/Views/FeedView.swift b/yana/Views/FeedView.swift index 8483ff1..942edee 100644 --- a/yana/Views/FeedView.swift +++ b/yana/Views/FeedView.swift @@ -22,7 +22,7 @@ struct FeedView: View { // 右侧加号按钮 Button(action: { - // 加号按钮操作 + store.send(.showCreateFeed) }) { Image("add icon") .frame(width: 36, height: 36) @@ -104,6 +104,20 @@ struct FeedView: View { .onAppear { store.send(.onAppear) } + .sheet(isPresented: .init( + get: { store.isShowingCreateFeed }, + set: { _ in store.send(.dismissCreateFeed) } + )) { + CreateFeedView( + store: Store(initialState: CreateFeedFeature.State()) { + CreateFeedFeature() + } + ) + .onDisappear { + // 当CreateFeedView消失时,可能需要刷新数据 + // 这里可以根据需要添加逻辑 + } + } } } }