diff --git a/yana/Features/AppSettingFeature.swift b/yana/Features/AppSettingFeature.swift index 3362432..d91a770 100644 --- a/yana/Features/AppSettingFeature.swift +++ b/yana/Features/AppSettingFeature.swift @@ -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 } } } diff --git a/yana/Info.plist b/yana/Info.plist index d5b7ef5..3aae323 100644 --- a/yana/Info.plist +++ b/yana/Info.plist @@ -9,6 +9,8 @@ NSWiFiUsageDescription 应用需要访问 Wi-Fi 信息以提供网络相关功能 + NSCameraUsageDescription + 需要使用相机拍照上传图片 UIAppFonts Bayon-Regular.ttf diff --git a/yana/Views/AppSettingView.swift b/yana/Views/AppSettingView.swift index 585b669..24d8a58 100644 --- a/yana/Views/AppSettingView.swift +++ b/yana/Views/AppSettingView.swift @@ -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( + 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) -> 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) -> 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) diff --git a/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewReducer.swift b/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewReducer.swift index 90de29d..942dd6e 100644 --- a/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewReducer.swift +++ b/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewReducer.swift @@ -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 } } } diff --git a/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewView.swift b/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewView.swift index 96ef997..6c38195 100644 --- a/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewView.swift +++ b/yana/Views/Components/ImagePickerWithPreview/ImagePickerWithPreviewView.swift @@ -5,13 +5,15 @@ import PhotosUI public struct ImagePickerWithPreviewView: View { let store: StoreOf let onUpload: ([UIImage]) -> Void + let onCancel: () -> Void @State private var loadedImages: [UIImage] = [] @State private var isLoadingImages: Bool = false - public init(store: StoreOf, onUpload: @escaping ([UIImage]) -> Void) { + public init(store: StoreOf, 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 + 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 { } ) } -} \ No newline at end of file +} diff --git a/yana/Views/MainView.swift b/yana/Views/MainView.swift index 6105287..2a9ea4d 100644 --- a/yana/Views/MainView.swift +++ b/yana/Views/MainView.swift @@ -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 + + 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) -> 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 + 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() + } + } + } +}