feat: 新增图片源选择功能以增强头像设置体验

- 添加AppImageSource枚举以定义图片源类型(相机和相册)。
- 在AppSettingFeature中新增状态和Action以管理图片源选择。
- 更新AppSettingView以支持图片源选择的ActionSheet和头像选择逻辑。
- 优化ImagePickerWithPreviewView以处理相机和相册选择的取消操作。
This commit is contained in:
edwinQQQ
2025-07-31 17:29:38 +08:00
parent 57a8b833eb
commit 17ad000e4b
3 changed files with 161 additions and 140 deletions

View File

@@ -1,6 +1,12 @@
import Foundation import Foundation
import ComposableArchitecture import ComposableArchitecture
//
enum AppImageSource: Equatable {
case camera
case photoLibrary
}
@Reducer @Reducer
struct AppSettingFeature { struct AppSettingFeature {
@ObservableState @ObservableState
@@ -37,6 +43,10 @@ struct AppSettingFeature {
} }
// TCA // TCA
var showImagePicker: Bool = false var showImagePicker: Bool = false
// ActionSheet
var showImageSourceActionSheet: Bool = false
//
var selectedImageSource: AppImageSource? = nil
} }
enum Action: Equatable { enum Action: Equatable {
@@ -73,6 +83,10 @@ struct AppSettingFeature {
case testPushTapped case testPushTapped
// TCA // TCA
case setShowImagePicker(Bool) case setShowImagePicker(Bool)
// ActionSheet
case setShowImageSourceActionSheet(Bool)
//
case selectImageSource(AppImageSource)
} }
@Dependency(\.apiService) var apiService @Dependency(\.apiService) var apiService
@@ -254,6 +268,15 @@ struct AppSettingFeature {
case .setShowImagePicker(let show): case .setShowImagePicker(let show):
state.showImagePicker = show state.showImagePicker = show
return .none return .none
case .setShowImageSourceActionSheet(let show):
state.showImageSourceActionSheet = show
return .none
case .selectImageSource(let source):
state.showImageSourceActionSheet = false
state.showImagePicker = true
state.selectedImageSource = source
// ImagePickerWithPreviewView
return .none
} }
} }
} }

View File

@@ -16,24 +16,14 @@ struct AppSettingView: View {
initialState: ImagePickerWithPreviewReducer.State(inner: ImagePickerWithPreviewState(selectionMode: .single)), initialState: ImagePickerWithPreviewReducer.State(inner: ImagePickerWithPreviewState(selectionMode: .single)),
reducer: { ImagePickerWithPreviewReducer() } 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 { var body: some View {
WithPerceptionTracking { WithPerceptionTracking {
mainView() mainView()
} }
.onAppear {
store.send(.onAppear)
}
} }
@ViewBuilder @ViewBuilder
@@ -63,7 +53,7 @@ struct AppSettingView: View {
Spacer() Spacer()
Text(LocalizedString("app_settings.title", comment: "设置")) Text(LocalizedString("appSetting.title", comment: "编辑"))
.font(.system(size: 18, weight: .medium)) .font(.system(size: 18, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
@@ -78,76 +68,92 @@ struct AppSettingView: View {
// //
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 0) {
// //
avatarSection() avatarSection()
.padding(.top, 20)
// //
personalInfoSection() personalInfoSection()
.padding(.top, 30)
// //
otherSettingsSection() otherSettingsSection()
.padding(.top, 20)
Spacer(minLength: 40)
// 退 // 退
logoutSection() logoutSection()
.padding(.bottom, 40)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 40)
} }
} }
} }
} }
.navigationBarHidden(true) .navigationBarHidden(true)
// ActionSheet
.confirmationDialog(
"请选择图片来源",
isPresented: Binding(
get: { store.showImageSourceActionSheet },
set: { store.send(.setShowImageSourceActionSheet($0)) }
),
titleVisibility: .visible
) {
Button(LocalizedString("app_settings.take_photo", comment: "拍照")) {
store.send(.selectImageSource(AppImageSource.camera))
//
pickerStore.send(.inner(.selectSource(.camera)))
}
Button(LocalizedString("app_settings.select_from_album", comment: "从相册选择")) {
store.send(.selectImageSource(AppImageSource.photoLibrary))
//
pickerStore.send(.inner(.selectSource(.photoLibrary)))
}
Button(LocalizedString("common.cancel", comment: "取消"), role: .cancel) { }
}
// //
.sheet(isPresented: $showImagePickerSheet) { .sheet(isPresented: Binding(
get: { store.showImagePicker },
set: { store.send(.setShowImagePicker($0)) }
)) {
ImagePickerWithPreviewView( ImagePickerWithPreviewView(
store: pickerStore, store: pickerStore,
onUpload: { images in onUpload: { images in
if let firstImage = images.first, if let firstImage = images.first,
let imageData = firstImage.jpegData(compressionQuality: 0.8) { let imageData = firstImage.jpegData(compressionQuality: 0.8) {
store.send(AppSettingFeature.Action.avatarSelected(imageData)) store.send(.avatarSelected(imageData))
} }
showImagePickerSheet = false store.send(.setShowImagePicker(false))
}, },
onCancel: { onCancel: {
showImagePickerSheet = false store.send(.setShowImagePicker(false))
}
)
}
//
.sheet(isPresented: $showCamera) {
ImagePickerWithPreviewView(
store: pickerStore,
onUpload: { images in
if let firstImage = images.first,
let imageData = firstImage.jpegData(compressionQuality: 0.8) {
store.send(AppSettingFeature.Action.avatarSelected(imageData))
}
showCamera = false
},
onCancel: {
showCamera = false
} }
) )
} }
// //
.alert(LocalizedString("app_settings.edit_nickname", comment: "编辑昵称"), isPresented: $showNicknameAlert) { .alert(LocalizedString("appSetting.nickname", comment: "编辑昵称"), isPresented: Binding(
TextField(LocalizedString("app_settings.nickname_placeholder", comment: "请输入昵称"), text: $nicknameInput) get: { store.isEditingNickname },
Button(LocalizedString("app_settings.cancel", comment: "取消")) { set: { store.send(.nicknameEditAlert($0)) }
showNicknameAlert = false )) {
nicknameInput = "" TextField(LocalizedString("appSetting.nickname", comment: "请输入昵称"), text: Binding(
get: { store.nicknameInput },
set: { store.send(.nicknameInputChanged($0)) }
))
Button(LocalizedString("common.cancel", comment: "取消")) {
store.send(.nicknameEditAlert(false))
} }
Button(LocalizedString("app_settings.confirm", comment: "确认")) { Button(LocalizedString("common.confirm", comment: "确认")) {
let trimmed = nicknameInput.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = store.nicknameInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
store.send(.nicknameEditConfirmed(trimmed)) store.send(.nicknameEditConfirmed(trimmed))
} }
showNicknameAlert = false store.send(.nicknameEditAlert(false))
nicknameInput = ""
} }
} message: { } message: {
Text(LocalizedString("app_settings.nickname_tip", comment: "请输入新的昵称")) Text(LocalizedString("appSetting.nickname", comment: "请输入新的昵称"))
} }
} }
} }
@@ -159,8 +165,9 @@ struct AppSettingView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
// //
Button(action: { Button(action: {
showImagePickerSheet = true store.send(.setShowImageSourceActionSheet(true))
}) { }) {
ZStack {
AsyncImage(url: URL(string: store.userInfo?.avatar ?? "")) { image in AsyncImage(url: URL(string: store.userInfo?.avatar ?? "")) { image in
image image
.resizable() .resizable()
@@ -171,22 +178,28 @@ struct AppSettingView: View {
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.foregroundColor(.gray) .foregroundColor(.gray)
} }
.frame(width: 80, height: 80) .frame(width: 100, height: 100)
.clipShape(Circle()) .clipShape(Circle())
.overlay(
//
VStack {
Spacer()
HStack {
Spacer()
Circle() Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 2) .fill(Color.purple)
.frame(width: 32, height: 32)
.overlay(
Image(systemName: "camera")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
) )
} }
Text(LocalizedString("app_settings.tap_to_change_avatar", comment: "点击更换头像"))
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
} }
.padding(.vertical, 20) .frame(width: 100, height: 100)
.frame(maxWidth: .infinity) }
.background(Color.white.opacity(0.1)) }
.cornerRadius(12) }
} }
} }
@@ -198,28 +211,13 @@ struct AppSettingView: View {
// //
SettingRow( SettingRow(
icon: "person", icon: "person",
title: LocalizedString("app_settings.nickname", comment: "昵称"), title: LocalizedString("appSetting.nickname", comment: "昵称"),
subtitle: store.userInfo?.nick ?? LocalizedString("app_settings.not_set", comment: "未设置"), subtitle: store.userInfo?.nick ?? LocalizedString("app_settings.not_set", comment: "未设置"),
action: { action: {
nicknameInput = store.userInfo?.nick ?? "" store.send(.nicknameEditAlert(true))
showNicknameAlert = true
} }
) )
Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 50)
// ID
SettingRow(
icon: "number",
title: LocalizedString("app_settings.user_id", comment: "用户ID"),
subtitle: "\(store.userInfo?.uid ?? 0)",
action: nil
)
} }
.background(Color.white.opacity(0.1))
.cornerRadius(12)
} }
} }
@@ -230,8 +228,8 @@ struct AppSettingView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
SettingRow( SettingRow(
icon: "hand.raised", icon: "hand.raised",
title: LocalizedString("app_settings.personal_info_permissions", comment: "个人信息权限"), title: LocalizedString("appSetting.personalInfoPermissions", comment: "个人信息权限"),
subtitle: LocalizedString("app_settings.manage_permissions", comment: "管理权限"), subtitle: "",
action: { store.send(.personalInfoPermissionsTapped) } action: { store.send(.personalInfoPermissionsTapped) }
) )
@@ -241,8 +239,8 @@ struct AppSettingView: View {
SettingRow( SettingRow(
icon: "questionmark.circle", icon: "questionmark.circle",
title: LocalizedString("app_settings.help", comment: "帮助"), title: LocalizedString("appSetting.help", comment: "帮助"),
subtitle: LocalizedString("app_settings.get_help", comment: "获取帮助"), subtitle: "",
action: { store.send(.helpTapped) } action: { store.send(.helpTapped) }
) )
@@ -252,8 +250,8 @@ struct AppSettingView: View {
SettingRow( SettingRow(
icon: "trash", icon: "trash",
title: LocalizedString("app_settings.clear_cache", comment: "清除缓存"), title: LocalizedString("appSetting.clearCache", comment: "清除缓存"),
subtitle: LocalizedString("app_settings.free_up_space", comment: "释放空间"), subtitle: "",
action: { store.send(.clearCacheTapped) } action: { store.send(.clearCacheTapped) }
) )
@@ -263,8 +261,8 @@ struct AppSettingView: View {
SettingRow( SettingRow(
icon: "arrow.clockwise", icon: "arrow.clockwise",
title: LocalizedString("app_settings.check_updates", comment: "检查更新"), title: LocalizedString("appSetting.checkUpdates", comment: "检查更新"),
subtitle: LocalizedString("app_settings.latest_version", comment: "最新版本"), subtitle: "",
action: { store.send(.checkUpdatesTapped) } action: { store.send(.checkUpdatesTapped) }
) )
@@ -274,8 +272,8 @@ struct AppSettingView: View {
SettingRow( SettingRow(
icon: "person.crop.circle.badge.minus", icon: "person.crop.circle.badge.minus",
title: LocalizedString("app_settings.deactivate_account", comment: "注销账号"), title: LocalizedString("appSetting.deactivateAccount", comment: "注销账号"),
subtitle: LocalizedString("app_settings.permanent_deletion", comment: "永久删除"), subtitle: "",
action: { store.send(.deactivateAccountTapped) } action: { store.send(.deactivateAccountTapped) }
) )
@@ -285,13 +283,11 @@ struct AppSettingView: View {
SettingRow( SettingRow(
icon: "info.circle", icon: "info.circle",
title: LocalizedString("app_settings.about_us", comment: "关于我们"), title: LocalizedString("appSetting.aboutUs", comment: "关于我们"),
subtitle: LocalizedString("app_settings.app_info", comment: "应用信息"), subtitle: "",
action: { store.send(.aboutUsTapped) } action: { store.send(.aboutUsTapped) }
) )
} }
.background(Color.white.opacity(0.1))
.cornerRadius(12)
} }
} }
@@ -299,10 +295,12 @@ struct AppSettingView: View {
@ViewBuilder @ViewBuilder
private func logoutSection() -> some View { private func logoutSection() -> some View {
WithPerceptionTracking { WithPerceptionTracking {
VStack(spacing: 12) {
// 退
Button(action: { Button(action: {
store.send(.logoutTapped) store.send(.logoutTapped)
}) { }) {
Text(LocalizedString("app_settings.logout", comment: "退出登录")) Text(LocalizedString("appSetting.logoutAccount", comment: "退出账户"))
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -312,6 +310,7 @@ struct AppSettingView: View {
} }
} }
} }
}
} }
// MARK: - // MARK: -
@@ -336,10 +335,12 @@ struct SettingRow: View {
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundColor(.white) .foregroundColor(.white)
if !subtitle.isEmpty {
Text(subtitle) Text(subtitle)
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
} }
}
Spacer() Spacer()

View File

@@ -23,10 +23,10 @@ public struct ImagePickerWithPreviewView: View {
Color.clear Color.clear
} }
.background(.clear) .background(.clear)
.modifier(ActionSheetModifier(viewStore: viewStore, onCancel: onCancel)) .ignoresSafeArea()
.modifier(CameraSheetModifier(viewStore: viewStore)) .modifier(CameraSheetModifier(viewStore: viewStore, onCancel: onCancel))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages, loadingId: $loadingId)) .modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages, loadingId: $loadingId, onCancel: onCancel))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload, loadingId: $loadingId)) .modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload, loadingId: $loadingId, onCancel: onCancel))
.modifier(ErrorToastModifier(viewStore: viewStore)) .modifier(ErrorToastModifier(viewStore: viewStore))
.onChange(of: viewStore.inner.isLoading) { isLoading in .onChange(of: viewStore.inner.isLoading) { isLoading in
if isLoading && loadingId == nil { if isLoading && loadingId == nil {
@@ -40,34 +40,21 @@ public struct ImagePickerWithPreviewView: View {
} }
} }
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(LocalizedString("app_settings.take_photo", comment: "")) { viewStore.send(.inner(.selectSource(.camera))) }
Button(LocalizedString("app_settings.select_from_album", comment: "")) { viewStore.send(.inner(.selectSource(.photoLibrary))) }
Button("取消", role: .cancel) { onCancel() }
}
}
}
private struct CameraSheetModifier: ViewModifier { private struct CameraSheetModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer> let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
let onCancel: () -> Void
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.sheet(isPresented: .init( content.sheet(isPresented: .init(
get: { viewStore.inner.showCamera }, get: { viewStore.inner.showCamera },
set: { viewStore.send(.inner(.setShowCamera($0))) } set: { viewStore.send(.inner(.setShowCamera($0))) }
)) { )) {
CameraPicker { image in CameraPicker { image in
if let image = image {
viewStore.send(.inner(.cameraImagePicked(image))) viewStore.send(.inner(.cameraImagePicked(image)))
} else {
//
onCancel()
}
} }
} }
} }
@@ -78,12 +65,20 @@ private struct PhotosPickerModifier: ViewModifier {
@Binding var loadedImages: [UIImage] @Binding var loadedImages: [UIImage]
@Binding var isLoadingImages: Bool @Binding var isLoadingImages: Bool
@Binding var loadingId: UUID? @Binding var loadingId: UUID?
let onCancel: () -> Void
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.photosPicker( .photosPicker(
isPresented: .init( isPresented: .init(
get: { viewStore.inner.showPhotoPicker }, get: { viewStore.inner.showPhotoPicker },
set: { viewStore.send(.inner(.setShowPhotoPicker($0))) } set: { show in
viewStore.send(.inner(.setShowPhotoPicker(show)))
//
if !show && viewStore.inner.selectedPhotoItems.isEmpty {
onCancel()
}
}
), ),
selection: .init( selection: .init(
get: { viewStore.inner.selectedPhotoItems }, get: { viewStore.inner.selectedPhotoItems },
@@ -131,6 +126,7 @@ private struct PreviewCoverModifier: ViewModifier {
let loadedImages: [UIImage] let loadedImages: [UIImage]
let onUpload: ([UIImage]) -> Void let onUpload: ([UIImage]) -> Void
@Binding var loadingId: UUID? @Binding var loadingId: UUID?
let onCancel: () -> Void
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.fullScreenCover(isPresented: .init( content.fullScreenCover(isPresented: .init(
get: { viewStore.inner.showPreview }, get: { viewStore.inner.showPreview },
@@ -148,6 +144,7 @@ private struct PreviewCoverModifier: ViewModifier {
}, },
onCancel: { onCancel: {
viewStore.send(.inner(.previewCancel)) viewStore.send(.inner(.previewCancel))
onCancel()
} }
) )
} }