feat: 移除CreateFeedView-Analysis文档并新增用户协议组件以增强用户体验

- 删除CreateFeedView-Analysis.md文档以简化项目结构。
- 新增UserAgreementComponent以处理用户协议的显示和交互。
- 更新多个视图中的onChange逻辑以兼容iOS 17的新API用法,确保代码一致性和可维护性。
- 在Localizable.strings中新增用户协议相关的本地化文本,提升多语言支持。
This commit is contained in:
edwinQQQ
2025-08-01 14:34:53 +08:00
parent fdfa39f0b7
commit b35b6e1ce1
16 changed files with 352 additions and 175 deletions

View File

@@ -1,79 +0,0 @@
# 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。

View File

@@ -0,0 +1,67 @@
# onChange iOS 17 迁移总结
## 概述
将项目中所有使用已弃用的 `onChange(of:perform:)` API 的代码修改为 iOS 17 建议的新用法。
## 修改内容
### 修改规则
- **旧用法**: `onChange(of: value) { newValue in ... }`
- **新用法**: `onChange(of: value) { oldValue, newValue in ... }`
### 修改的文件列表
1. **LoginView.swift** - 3处修改
- `store.isAnyLoginCompleted` 监听
- `showIDLogin` 监听
- `showEmailLogin` 监听
2. **MainView.swift** - 3处修改
- `store.isLoggedOut` 监听
- `path` 监听
- `store.navigationPath` 监听
3. **EMailLoginView.swift** - 4处修改
- `store.loginStep` 监听
- `email` 监听
- `verificationCode` 监听
- `store.isCodeLoading` 监听
4. **RecoverPasswordView.swift** - 4处修改
- `email` 监听
- `verificationCode` 监听
- `newPassword` 监听
- `store.isResetSuccess` 监听
5. **ImagePickerWithPreviewView.swift** - 2处修改
- `viewStore.inner.isLoading` 监听
- `viewStore.inner.selectedPhotoItems` 监听
6. **EditFeedView.swift** - 1处修改
- `store.shouldDismiss` 监听
7. **DetailView.swift** - 1处修改
- `store.shouldDismiss` 监听
8. **MeView.swift** - 1处修改
- `detailStore.shouldDismiss` 监听
9. **IDLoginView.swift** - 1处修改
- `store.loginStep` 监听
10. **ContentView.swift** - 1处修改
- `selectedLogLevel` 监听
## 总计
- **修改文件数**: 10个
- **修改处数**: 20处
- **状态**: ✅ 完成
## 验证结果
通过 grep 搜索确认所有 `onChange(of:perform:)` 调用都已成功迁移到新 API。
## 注意事项
1. 新 API 提供了 `oldValue``newValue` 两个参数
2. 在大多数情况下,我们只使用了 `newValue` 参数,`oldValue``_` 忽略
3. 所有原有逻辑保持不变,只是 API 调用方式更新
4. 修改后的代码完全兼容 iOS 17+ 的要求

16
ui-demo.swift Normal file
View File

@@ -0,0 +1,16 @@
let label = UILabel()
let attrString = NSMutableAttributedString(string: "Agree to the "User Service Agreement" and "Privacy Policy"")
label.frame = CGRect(x: 71, y: 735, width: 256, height: 34)
label.numberOfLines = 0
let attr: [NSAttributedString.Key : Any] = [.font: UIFont(name: "PingFang SC-Regular", size: 12),.foregroundColor: UIColor(red: 1, green: 1, blue: 1, alpha: 1)]
attrString.addAttributes(attr, range: NSRange(location: 0, length: attrString.length))
view.addSubview(label)
let strSubAttr1: [NSMutableAttributedString.Key: Any] = [.font: UIFont(name: "PingFang SC-Regular", size: 12),.foregroundColor: UIColor(red: 1, green: 1, blue: 1,alpha:1)]
attrString.addAttributes(strSubAttr1, range: NSRange(location: 0, length: 13))
let strSubAttr2: [NSMutableAttributedString.Key: Any] = [.font: UIFont(name: "PingFang SC-Regular", size: 12),.foregroundColor: UIColor(red: 0.78, green: 0.35, blue: 1,alpha:1)]
attrString.addAttributes(strSubAttr2, range: NSRange(location: 13, length: 24))
let strSubAttr3: [NSMutableAttributedString.Key: Any] = [.font: UIFont(name: "PingFang SC-Regular", size: 12),.foregroundColor: UIColor(red: 1, green: 1, blue: 1,alpha:1)]
attrString.addAttributes(strSubAttr3, range: NSRange(location: 37, length: 5))
let strSubAttr4: [NSMutableAttributedString.Key: Any] = [.font: UIFont(name: "PingFang SC-Regular", size: 12),.foregroundColor: UIColor(red: 0.78, green: 0.35, blue: 1,alpha:1)]
attrString.addAttributes(strSubAttr4, range: NSRange(location: 42, length: 16))
label.attributedText = attrString

View File

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

View File

@@ -12,6 +12,9 @@
"login.agreement_policy" = "Agree to the \"User Service Agreement\" and \"Privacy Policy\"";
"login.agreement" = "User Service Agreement";
"login.policy" = "Privacy Policy";
"login.agreement_alert_title" = "Notice";
"login.agreement_alert_message" = "Please agree to the User Service Agreement and Privacy Policy first";
"login.agreement_alert_confirm" = "OK";
// MARK: - Common Buttons
"common.login" = "Login";

View File

@@ -13,6 +13,9 @@
"login.agreement_policy" = "同意《用戶服務協議》和《隱私政策》";
"login.agreement" = "《用戶服務協議》";
"login.policy" = "《隱私政策》";
"login.agreement_alert_title" = "提示";
"login.agreement_alert_message" = "请先同意用户服务协议和隐私政策";
"login.agreement_alert_confirm" = "确定";
// MARK: - 通用按钮
"common.login" = "登录";

View File

@@ -22,13 +22,13 @@ public struct ImagePickerWithPreviewView: View {
ZStack {
Color.clear
}
.background(.clear)
.background(.red)
.ignoresSafeArea()
.modifier(CameraSheetModifier(viewStore: viewStore, onCancel: onCancel))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages, loadingId: $loadingId, onCancel: onCancel))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload, loadingId: $loadingId, onCancel: onCancel))
.modifier(ErrorToastModifier(viewStore: viewStore))
.onChange(of: viewStore.inner.isLoading) { isLoading in
.onChange(of: viewStore.inner.isLoading) { _, isLoading in
if isLoading && loadingId == nil {
loadingId = APILoadingManager.shared.startLoading()
} else if !isLoading, let id = loadingId {
@@ -92,7 +92,7 @@ private struct PhotosPickerModifier: ViewModifier {
}(),
matching: .images
)
.onChange(of: viewStore.inner.selectedPhotoItems) { items in
.onChange(of: viewStore.inner.selectedPhotoItems) { _, items in
guard !items.isEmpty else { return }
isLoadingImages = true
loadedImages = []

View File

@@ -0,0 +1,165 @@
import SwiftUI
struct UserAgreementComponent: View {
@Binding var isAgreed: Bool
let onAgreementTap: @Sendable () -> Void
let onPolicyTap: @Sendable () -> Void
var body: some View {
HStack(alignment: .center, spacing: 12) {
//
Button(action: {
isAgreed.toggle()
}) {
Image(isAgreed ? "selected icon" : "unselected icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
}
.buttonStyle(PlainButtonStyle())
//
RichTextAgreementView(
isAgreed: $isAgreed,
onAgreementTap: onAgreementTap,
onPolicyTap: onPolicyTap
)
}
}
}
struct RichTextAgreementView: UIViewRepresentable {
@Binding var isAgreed: Bool
let onAgreementTap: @Sendable () -> Void
let onPolicyTap: @Sendable () -> Void
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.numberOfLines = 3
label.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
label.addGestureRecognizer(tapGesture)
return label
}
func updateUIView(_ label: UILabel, context: Context) {
let fullText = LocalizedString("login.agreement_policy", comment: "")
let agreementText = LocalizedString("login.agreement", comment: "")
let policyText = LocalizedString("login.policy", comment: "")
let attrString = NSMutableAttributedString(string: fullText)
//
let baseAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.white
]
attrString.addAttributes(baseAttributes, range: NSRange(location: 0, length: attrString.length))
//
if let agreementRange = fullText.range(of: agreementText) {
let nsRange = NSRange(agreementRange, in: fullText)
let agreementAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor(red: 0.78, green: 0.35, blue: 1, alpha: 1),
.underlineStyle: NSUnderlineStyle.single.rawValue
]
attrString.addAttributes(agreementAttributes, range: nsRange)
context.coordinator.agreementRange = nsRange
}
if let policyRange = fullText.range(of: policyText) {
let nsRange = NSRange(policyRange, in: fullText)
let policyAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor(red: 0.78, green: 0.35, blue: 1, alpha: 1),
.underlineStyle: NSUnderlineStyle.single.rawValue
]
attrString.addAttributes(policyAttributes, range: nsRange)
context.coordinator.policyRange = nsRange
}
label.attributedText = attrString
}
func makeCoordinator() -> Coordinator {
Coordinator(onAgreementTap: onAgreementTap, onPolicyTap: onPolicyTap)
}
class Coordinator: NSObject {
let onAgreementTap: @Sendable () -> Void
let onPolicyTap: @Sendable () -> Void
var agreementRange: NSRange?
var policyRange: NSRange?
init(onAgreementTap: @escaping @Sendable () -> Void, onPolicyTap: @escaping @Sendable () -> Void) {
self.onAgreementTap = onAgreementTap
self.onPolicyTap = onPolicyTap
}
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
// Task访self
let agreementRange = self.agreementRange
let policyRange = self.policyRange
let onAgreementTap = self.onAgreementTap
let onPolicyTap = self.onPolicyTap
Task { @MainActor in
guard let label = gesture.view as? UILabel,
let attributedText = label.attributedText else { return }
let location = gesture.location(in: label)
//
let textContainer = NSTextContainer(size: label.bounds.size)
textContainer.lineFragmentPadding = 0
textContainer.maximumNumberOfLines = label.numberOfLines
textContainer.lineBreakMode = label.lineBreakMode
//
let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
//
let textStorage = NSTextStorage(attributedString: attributedText)
textStorage.addLayoutManager(layoutManager)
//
let characterIndex = layoutManager.characterIndex(
for: location,
in: textContainer,
fractionOfDistanceBetweenInsertionPoints: nil
)
//
if let agreementRange = agreementRange,
NSLocationInRange(characterIndex, agreementRange) {
await MainActor.run {
onAgreementTap()
}
return
}
if let policyRange = policyRange,
NSLocationInRange(characterIndex, policyRange) {
await MainActor.run {
onPolicyTap()
}
return
}
}
}
}
}
#Preview {
UserAgreementComponent(
isAgreed: .constant(true),
onAgreementTap: {},
onPolicyTap: {}
)
.padding()
.background(Color.black)
}

View File

@@ -67,7 +67,7 @@ struct DetailView: View {
debugInfoSync("🔍 DetailView: onAppear - moment.uid: \(store.moment.uid)")
store.send(.onAppear)
}
.onChange(of: store.shouldDismiss) { shouldDismiss in
.onChange(of: store.shouldDismiss) { _, shouldDismiss in
if shouldDismiss {
debugInfoSync("🔍 DetailView: shouldDismiss = true, 调用onDismiss")
// onDismiss?() 使 dismiss

View File

@@ -51,7 +51,7 @@ struct EMailLoginView: View {
getCodeButtonText: getCodeButtonText,
isCodeButtonEnabled: isCodeButtonEnabled
)
.onChange(of: store.loginStep) { newStep in
.onChange(of: store.loginStep) { _, newStep in
debugInfoSync("🔄 EMailLoginView: loginStep 变化为 \(newStep)")
if newStep == .completed {
debugInfoSync("✅ EMailLoginView: 登录成功,准备关闭自身")
@@ -77,17 +77,17 @@ struct EMailLoginView: View {
stopCountdown()
}
}
.onChange(of: email) { newEmail in
.onChange(of: email) { _, newEmail in
let _ = WithPerceptionTracking {
store.send(.emailChanged(newEmail))
}
}
.onChange(of: verificationCode) { newCode in
.onChange(of: verificationCode) { _, newCode in
let _ = WithPerceptionTracking {
store.send(.verificationCodeChanged(newCode))
}
}
.onChange(of: store.isCodeLoading) { isCodeLoading in
.onChange(of: store.isCodeLoading) { _, isCodeLoading in
let _ = WithPerceptionTracking {
if !isCodeLoading && store.errorMessage == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {

View File

@@ -44,8 +44,8 @@ struct EditFeedView: View {
.onAppear {
store.send(.clearError)
}
.onChange(of: store.shouldDismiss) {
if store.shouldDismiss {
.onChange(of: store.shouldDismiss) { _, shouldDismiss in
if shouldDismiss {
onDismiss()
}
}

View File

@@ -299,7 +299,7 @@ struct IDLoginView: View {
#endif
}
}
.onChange(of: store.loginStep) { newStep in
.onChange(of: store.loginStep) { _, newStep in
debugInfoSync("🔄 IDLoginView: loginStep 变化为 \(newStep)")
if newStep == .completed {
debugInfoSync("✅ IDLoginView: 登录成功,准备关闭自身")

View File

@@ -2,14 +2,6 @@ import SwiftUI
import ComposableArchitecture
import Perception
// PreferenceKey
struct ImageHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct LoginView: View {
let store: StoreOf<LoginFeature>
let onLoginSuccess: () -> Void
@@ -20,16 +12,44 @@ struct LoginView: View {
@State private var showLanguageSettings: Bool = false
@State private var showUserAgreement: Bool = false
@State private var showPrivacyPolicy: Bool = false
@State private var isAgreementAccepted: Bool = true //
@State private var showAgreementAlert: Bool = false
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
backgroundView
mainContentView(geometry: geometry)
VStack(spacing: 0) {
Image("top")
.resizable()
.aspectRatio(375/400, contentMode: .fit)
.frame(maxWidth: .infinity)
HStack {
Text(LocalizedString("login.app_title", comment: ""))
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
.foregroundColor(.white)
.padding(.leading, 20)
Spacer()
}
.padding(.bottom, 20) // top
Spacer()
bottomSection
}
// -
languageSettingsButton
.position(x: geometry.size.width - 40, y: 60)
APILoadingEffectView()
}
}
.ignoresSafeArea()
.navigationBarHidden(true)
.navigationDestination(isPresented: $showIDLogin) {
IDLoginView(
@@ -68,17 +88,22 @@ struct LoginView: View {
isPresented: $showPrivacyPolicy,
url: APIConfiguration.webURL(for: .privacyPolicy)
)
.onChange(of: store.isAnyLoginCompleted) {
if store.isAnyLoginCompleted {
.alert(LocalizedString("login.agreement_alert_title", comment: ""), isPresented: $showAgreementAlert) {
Button(LocalizedString("login.agreement_alert_confirm", comment: "")) { }
} message: {
Text(LocalizedString("login.agreement_alert_message", comment: ""))
}
.onChange(of: store.isAnyLoginCompleted) { _, isAnyLoginCompleted in
if isAnyLoginCompleted {
onLoginSuccess()
}
}
.onChange(of: showIDLogin) {
.onChange(of: showIDLogin) { _, showIDLogin in
if showIDLogin == false && store.isAnyLoginCompleted {
onLoginSuccess()
}
}
.onChange(of: showEmailLogin) {
.onChange(of: showEmailLogin) { _, showEmailLogin in
if showEmailLogin == false && store.isAnyLoginCompleted {
onLoginSuccess()
}
@@ -95,44 +120,13 @@ struct LoginView: View {
.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
userAgreementComponent
}
.padding(.horizontal, 28)
.padding(.bottom, 140)
.padding(.bottom, 48)
}
private var loginButtons: some View {
@@ -142,7 +136,11 @@ struct LoginView: View {
iconColor: .blue,
title: LocalizedString("login.id_login", comment: ""),
action: {
showIDLogin = true
if isAgreementAccepted {
showIDLogin = true
} else {
showAgreementAlert = true
}
}
)
@@ -151,39 +149,43 @@ struct LoginView: View {
iconColor: .green,
title: LocalizedString("login.email_login", comment: ""),
action: {
showEmailLogin = true
if isAgreementAccepted {
showEmailLogin = true
} else {
showAgreementAlert = true
}
}
)
}
}
private var bottomButtons: some View {
HStack(spacing: 20) {
Button(action: {
showLanguageSettings = true
}) {
Text(LocalizedString("setting.language", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showUserAgreement = true
}) {
Text(LocalizedString("login.agreement", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
Button(action: {
showPrivacyPolicy = true
}) {
Text(LocalizedString("login.policy", comment: ""))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
private var languageSettingsButton: some View {
Button(action: {
showLanguageSettings = true
}) {
Image(systemName: "globe")
.font(.system(size: 20))
.foregroundColor(.white.opacity(0.8))
}
}
private var userAgreementComponent: some View {
UserAgreementComponent(
isAgreed: $isAgreementAccepted,
onAgreementTap: {
Task { @MainActor in
showUserAgreement = true
}
},
onPolicyTap: {
Task { @MainActor in
showPrivacyPolicy = true
}
}
)
.frame(height: 40)
.padding(.horizontal, -20)
}
}
//#Preview {

View File

@@ -8,8 +8,8 @@ struct MainView: View {
var body: some View {
WithPerceptionTracking {
InternalMainView(store: store)
.onChange(of: store.isLoggedOut) {
if store.isLoggedOut {
.onChange(of: store.isLoggedOut) { _, isLoggedOut in
if isLoggedOut {
onLogout?()
}
}
@@ -32,12 +32,12 @@ struct InternalMainView: View {
.navigationDestination(for: MainFeature.Destination.self) { destination in
DestinationView(destination: destination, store: self.store)
}
.onChange(of: path) {
.onChange(of: path) { _, path in
store.send(.navigationPathChanged(path))
}
.onChange(of: store.navigationPath) {
if path != store.navigationPath {
path = store.navigationPath
.onChange(of: store.navigationPath) { _, navigationPath in
if path != navigationPath {
path = navigationPath
}
}
.onAppear {

View File

@@ -72,7 +72,7 @@ struct MeView: View {
}
DetailView(store: detailStore)
.onChange(of: detailStore.shouldDismiss) { shouldDismiss in
.onChange(of: detailStore.shouldDismiss) { _, shouldDismiss in
if shouldDismiss {
store.send(.detailDismissed)
}

View File

@@ -135,16 +135,16 @@ struct RecoverPasswordView: View {
.onDisappear {
stopCountdown()
}
.onChange(of: email) { newEmail in
.onChange(of: email) { _, newEmail in
store.send(.emailChanged(newEmail))
}
.onChange(of: verificationCode) { newCode in
.onChange(of: verificationCode) { _, newCode in
store.send(.verificationCodeChanged(newCode))
}
.onChange(of: newPassword) { newPassword in
.onChange(of: newPassword) { _, newPassword in
store.send(.newPasswordChanged(newPassword))
}
.onChange(of: store.isResetSuccess) { isResetSuccess in
.onChange(of: store.isResetSuccess) { _, isResetSuccess in
if isResetSuccess {
onBack()
}