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

View File

@@ -23,10 +23,10 @@ public struct ImagePickerWithPreviewView: View {
Color.clear
}
.background(.clear)
.modifier(ActionSheetModifier(viewStore: viewStore, onCancel: onCancel))
.modifier(CameraSheetModifier(viewStore: viewStore))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages, loadingId: $loadingId))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload, loadingId: $loadingId))
.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
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 {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
let onCancel: () -> Void
func body(content: Content) -> some View {
content.sheet(isPresented: .init(
get: { viewStore.inner.showCamera },
set: { viewStore.send(.inner(.setShowCamera($0))) }
)) {
CameraPicker { image in
viewStore.send(.inner(.cameraImagePicked(image)))
if let image = image {
viewStore.send(.inner(.cameraImagePicked(image)))
} else {
//
onCancel()
}
}
}
}
@@ -78,12 +65,20 @@ private struct PhotosPickerModifier: ViewModifier {
@Binding var loadedImages: [UIImage]
@Binding var isLoadingImages: Bool
@Binding var loadingId: UUID?
let onCancel: () -> Void
func body(content: Content) -> some View {
content
.photosPicker(
isPresented: .init(
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(
get: { viewStore.inner.selectedPhotoItems },
@@ -131,6 +126,7 @@ private struct PreviewCoverModifier: ViewModifier {
let loadedImages: [UIImage]
let onUpload: ([UIImage]) -> Void
@Binding var loadingId: UUID?
let onCancel: () -> Void
func body(content: Content) -> some View {
content.fullScreenCover(isPresented: .init(
get: { viewStore.inner.showPreview },
@@ -148,6 +144,7 @@ private struct PreviewCoverModifier: ViewModifier {
},
onCancel: {
viewStore.send(.inner(.previewCancel))
onCancel()
}
)
}