feat: 更新Info.plist和AppSettingView以支持相机和图片选择功能

- 在Info.plist中新增相机使用说明,确保应用能够访问相机功能。
- 在AppSettingFeature中新增showImagePicker状态和setShowImagePicker动作,支持图片选择弹窗的显示。
- 在AppSettingView中整合图片选择与预览功能,优化用户体验。
- 更新MainView以简化导航逻辑,提升代码可读性与维护性。
- 在ImagePickerWithPreview组件中实现相机和相册选择功能,增强交互性。
This commit is contained in:
edwinQQQ
2025-07-25 18:47:11 +08:00
parent 2f3ef22ce5
commit ac0d622c97
6 changed files with 178 additions and 71 deletions

View File

@@ -29,6 +29,8 @@ struct AppSettingFeature {
self.avatarURL = avatarURL
self.userInfo = userInfo
}
// TCA
var showImagePicker: Bool = false
}
enum Action: Equatable {
@@ -61,6 +63,8 @@ struct AppSettingFeature {
case nicknameInputChanged(String)
case nicknameEditAlert(Bool)
case testPushTapped
// TCA
case setShowImagePicker(Bool)
}
@Dependency(\.apiService) var apiService
@@ -231,6 +235,9 @@ struct AppSettingFeature {
return .none
case .testPushTapped:
return .none
case .setShowImagePicker(let show):
state.showImagePicker = show
return .none
}
}
}

View File

@@ -9,6 +9,8 @@
</dict>
<key>NSWiFiUsageDescription</key>
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
<key>NSCameraUsageDescription</key>
<string>需要使用相机拍照上传图片</string>
<key>UIAppFonts</key>
<array>
<string>Bayon-Regular.ttf</string>

View File

@@ -16,41 +16,106 @@ struct AppSettingView: View {
initialState: ImagePickerWithPreviewReducer.State(inner: ImagePickerWithPreviewState(selectionMode: .single)),
reducer: { ImagePickerWithPreviewReducer() }
)
@State private var showImagePicker = false
@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 {
WithViewStore(store, observe: { $0 }) { viewStore in
ZStack {
mainContent(viewStore: viewStore)
if showImagePicker {
WithPerceptionTracking{
ImagePickerWithPreviewView(store: pickerStore) { images in
if let image = images.first, let data = image.jpegData(compressionQuality: 0.8) {
viewStore.send(.avatarSelected(data))
}
showImagePicker = false
WithPerceptionTracking {
ZStack {
mainContent(viewStore: viewStore)
}
.confirmationDialog(
"请选择图片来源",
isPresented: $showActionSheet,
titleVisibility: .visible
) {
Button("拍照") { showCamera = true }
Button("从相册选择") { showPhotoPicker = true }
Button("取消", role: .cancel) {}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotoItems,
maxSelectionCount: 1,
matching: .images
)
.sheet(isPresented: $showCamera) {
CameraPicker { image in
if let image = image {
selectedImages = [image]
showPreview = true
}
.zIndex(10)
showCamera = false
}
}
}
.navigationBarHidden(true)
.alert("修改昵称", isPresented: $showNicknameAlert) {
nicknameAlertContent(viewStore: viewStore)
} message: {
Text("昵称最长15个字符")
}
.sheet(isPresented: userAgreementBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/user-agreement")!)
}
.sheet(isPresented: privacyPolicyBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/privacy-policy")!)
}
.onChange(of: showImagePicker) { newValue in
if newValue {
pickerStore.send(.inner(.showActionSheet(true)))
.fullScreenCover(isPresented: $showPreview) {
ImagePreviewView(
images: selectedImages,
currentIndex: .constant(0),
onConfirm: {
if let image = selectedImages.first, let data = image.jpegData(compressionQuality: 0.8) {
viewStore.send(AppSettingFeature.Action.avatarSelected(data))
}
showPreview = false
},
onCancel: {
showPreview = false
}
)
}
.onChange(of: selectedPhotoItems) { items in
guard !items.isEmpty else { return }
isLoading = true
selectedImages = []
let group = DispatchGroup()
var tempImages: [UIImage] = []
for item in items {
group.enter()
item.loadTransferable(type: Data.self) { result in
defer { group.leave() }
if let data = try? result.get(), let uiImage = UIImage(data: data) {
tempImages.append(uiImage)
}
}
}
DispatchQueue.global().async {
group.wait()
DispatchQueue.main.async {
selectedImages = tempImages
isLoading = false
showPreview = !tempImages.isEmpty
}
}
}
.alert(isPresented: Binding<Bool>(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Alert(title: Text("错误"), message: Text(errorMessage ?? ""), dismissButton: .default(Text("确定")))
}
.navigationBarHidden(true)
.alert("修改昵称", isPresented: $showNicknameAlert) {
nicknameAlertContent(viewStore: viewStore)
} message: {
Text("昵称最长15个字符")
}
.sheet(isPresented: userAgreementBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/user-agreement")!)
}
.sheet(isPresented: privacyPolicyBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/privacy-policy")!)
}
}
}
@@ -85,12 +150,9 @@ struct AppSettingView: View {
ZStack(alignment: .bottomTrailing) {
avatarImageView(viewStore: viewStore)
.onTapGesture {
showImagePicker = true
}
cameraButton
.onTapGesture {
showImagePicker = true
showActionSheet = true
}
cameraButton(viewStore: viewStore)
}
.padding(.top, 24)
}
@@ -98,7 +160,7 @@ struct AppSettingView: View {
// MARK: -
@ViewBuilder
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
if viewStore.isLoadingUserInfo {
if viewStore.isUploadingAvatar || viewStore.isLoadingUserInfo {
loadingAvatarView
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
networkAvatarView(url: avatarURL)
@@ -145,8 +207,10 @@ struct AppSettingView: View {
}
// MARK: -
private var cameraButton: some View {
Button(action: {}) {
private func cameraButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
Button(action: {
showActionSheet = true
}) {
ZStack {
Circle().fill(Color.purple).frame(width: 36, height: 36)
Image(systemName: "camera.fill")
@@ -336,7 +400,7 @@ struct AppSettingView: View {
}
// MARK: -
private func loadAndProcessImage(item: PhotosPickerItem, completion: @escaping (Data?) -> Void) {
private func loadAndProcessImage(item: PhotosPickerItem, completion: @escaping @Sendable (Data?) -> Void) {
item.loadTransferable(type: Data.self) { result in
guard let data = try? result.get(), let uiImage = UIImage(data: data) else {
completion(nil)

View File

@@ -15,7 +15,7 @@ public struct ImagePickerWithPreviewState: Equatable {
public var showCamera: Bool = false
public var showPreview: Bool = false
public var isLoading: Bool = false
public var errorMessage: String?
public var errorMessage: String? = nil
public var selectedPhotoItems: [PhotosPickerItem] = []
public var selectedImages: [UIImage] = []
public var cameraImage: UIImage? = nil
@@ -41,6 +41,7 @@ public enum ImagePickerWithPreviewAction: Equatable {
case setPreviewIndex(Int)
case setShowCamera(Bool)
case setShowPhotoPicker(Bool)
case reset
}
public enum ImageSource: Equatable {
@@ -128,6 +129,10 @@ public struct ImagePickerWithPreviewReducer: Reducer {
case .setShowPhotoPicker(let show):
state.inner.showPhotoPicker = show
return .none
case .reset:
let mode = state.inner.selectionMode
state.inner = ImagePickerWithPreviewState(selectionMode: mode)
return .none
}
}
}

View File

@@ -5,13 +5,15 @@ import PhotosUI
public struct ImagePickerWithPreviewView: View {
let store: StoreOf<ImagePickerWithPreviewReducer>
let onUpload: ([UIImage]) -> Void
let onCancel: () -> Void
@State private var loadedImages: [UIImage] = []
@State private var isLoadingImages: Bool = false
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void) {
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void, onCancel: @escaping () -> Void) {
self.store = store
self.onUpload = onUpload
self.onCancel = onCancel
}
public var body: some View {
@@ -20,7 +22,8 @@ public struct ImagePickerWithPreviewView: View {
Color.clear
LoadingView(isLoading: viewStore.inner.isLoading || isLoadingImages)
}
.modifier(ActionSheetModifier(viewStore: viewStore))
.background(.clear)
.modifier(ActionSheetModifier(viewStore: viewStore, onCancel: onCancel))
.modifier(CameraSheetModifier(viewStore: viewStore))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload))
@@ -46,6 +49,7 @@ private struct LoadingView: View {
private struct ActionSheetModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
let onCancel: () -> Void
func body(content: Content) -> some View {
content.confirmationDialog(
"请选择图片来源",
@@ -57,7 +61,7 @@ private struct ActionSheetModifier: ViewModifier {
) {
Button("拍照") { viewStore.send(.inner(.selectSource(.camera))) }
Button("从相册选择") { viewStore.send(.inner(.selectSource(.photoLibrary))) }
Button("取消", role: .cancel) {}
Button("取消", role: .cancel) { onCancel() }
}
}
}
@@ -181,4 +185,4 @@ private struct ErrorToastModifier: ViewModifier {
}
)
}
}
}

View File

@@ -33,18 +33,7 @@ struct InternalMainView: View {
GeometryReader { geometry in
contentView(geometry: geometry, viewStore: viewStore)
.navigationDestination(for: MainFeature.Destination.self) { destination in
let view: AnyView
switch destination {
case .appSetting:
if let appSettingStore = store.scope(state: \.appSettingState, action: \.appSettingAction) {
view = AnyView(AppSettingView(store: appSettingStore))
} else {
view = AnyView(Text("appSettingState is nil"))
}
case .testView:
view = AnyView(TestView())
}
return view
DestinationView(destination: destination, store: self.store)
}
.onChange(of: path) { newPath in
viewStore.send(.navigationPathChanged(newPath))
@@ -63,6 +52,28 @@ struct InternalMainView: View {
}
}
struct DestinationView: View {
let destination: MainFeature.Destination
let store: StoreOf<MainFeature>
var body: some View {
switch destination {
case .appSetting:
IfLetStore(
store.scope(state: \.appSettingState, action: \.appSettingAction),
then: { store in
WithPerceptionTracking {
AppSettingView(store: store)
}
},
else: { Text("appSettingState is nil") }
)
case .testView:
TestView()
}
}
}
private func contentView(geometry: GeometryProxy, viewStore: ViewStoreOf<MainFeature>) -> some View {
WithPerceptionTracking {
ZStack {
@@ -74,31 +85,45 @@ struct InternalMainView: View {
.clipped()
.ignoresSafeArea(.all)
//
ZStack {
FeedListView(store: store.scope(
state: \.feedList,
action: \.feedList
))
.isHidden(viewStore.selectedTab != .feed)
MeView(
store: store.scope(
state: \.me,
action: \.me
)
)
.isHidden(viewStore.selectedTab != .other)
}
MainContentView(
store: store,
selectedTab: viewStore.selectedTab
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
VStack {
Spacer()
BottomTabView(selectedTab: viewStore.binding(
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
))
BottomTabView(selectedTab: viewStore.binding(
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
))
}
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
}
}
}
}
struct MainContentView: View {
let store: StoreOf<MainFeature>
let selectedTab: MainFeature.Tab
var body: some View {
Group {
if selectedTab == .feed {
FeedListView(store: store.scope(
state: \.feedList,
action: \.feedList
))
} else if selectedTab == .other {
MeView(
store: store.scope(
state: \.me,
action: \.me
)
)
} else {
EmptyView()
}
}
}
}