Files
suixinkan_ios_new/suixinkan/Features/Payment/Views/PaymentCollectionView.swift
汉秋 3dcfb99254 对齐 Android 立即收款页面 UI,并补齐推送状态与语音开关。
统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:17:09 +08:00

259 lines
9.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PaymentCollectionView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import AVFoundation
import Photos
import SwiftUI
///
struct PaymentCollectionView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.paymentAPI) private var paymentAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = PaymentCollectionViewModel()
@State private var showingAmountDialog = false
@State private var pollingTask: Task<Void, Never>?
private var scenicName: String {
accountContext.currentScenic?.name ?? "暂无景区"
}
private var staffID: String {
accountContext.profile?.userId ?? ""
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
PaymentQRSection(
scenicName: scenicName,
qrImage: viewModel.qrImage,
paymentSuccessData: viewModel.paymentSuccessData,
onSetAmount: openAmountDialog,
onSaveQRCode: saveQRCode,
onRefresh: refreshPayCode
)
PaymentStatusSection(
scanSuccessTime: viewModel.scanSuccessTime,
paymentSuccessData: viewModel.paymentSuccessData
)
infoCard
NavigationLink {
PaymentCollectionRecordView()
} label: {
PaymentNavigationRow(title: "收款记录")
}
.buttonStyle(.plain)
voiceToggleRow
if case .failed(let message) = viewModel.status {
PaymentWhiteCard {
Text(message)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.warning)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Self.pageBackground)
.navigationTitle("收款详情")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if showingAmountDialog {
PaymentSetAmountDialog(
amountText: $viewModel.amountText,
remarkText: $viewModel.remarkText,
canConfirm: viewModel.canConfirmAmount,
onConfirm: confirmAmount,
onDismiss: { showingAmountDialog = false }
)
}
}
.task(id: accountContext.currentScenic?.id) {
await globalLoading.withOptionalLoading(viewModel.qrImage == nil, message: "加载中...") {
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
.onDisappear {
pollingTask?.cancel()
}
.onChange(of: viewModel.errorMessage) { message in
if let message {
toastCenter.show(message)
}
}
}
/// //
private var infoCard: some View {
PaymentWhiteCard {
VStack(spacing: 6) {
PaymentInfoRow(label: "景区名称", value: scenicName)
PaymentInfoRow(label: "收款方", value: PaymentMerchantInfo.payeeCompany)
PaymentInfoRow(label: "员工ID", value: staffID)
}
}
}
///
private var voiceToggleRow: some View {
HStack {
Text("收款到账语音提醒")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer()
Toggle("", isOn: Binding(
get: { viewModel.isVoiceBroadcast },
set: { _ in viewModel.toggleVoiceBroadcast() }
))
.labelsHidden()
}
.frame(height: 52)
.padding(.horizontal, AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
///
private func openAmountDialog() {
if viewModel.prepareAmountDialog() {
showingAmountDialog = true
}
}
/// fallback
private func confirmAmount() {
if viewModel.confirmAmount() {
showingAmountDialog = false
toastCenter.show("金额设置成功,二维码已更新")
startPolling()
}
}
///
private func refreshPayCode() {
Task {
await globalLoading.withLoading(message: "刷新中...") {
await viewModel.loadPayCode(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
}
///
private func startPolling() {
pollingTask?.cancel()
pollingTask = Task {
await viewModel.primePaymentRecords(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
await viewModel.pollUntilPaymentDetected(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
///
private func saveQRCode() {
guard viewModel.hasStaticPayCode, let image = viewModel.qrImage else {
toastCenter.show("请先获取收款码")
return
}
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else {
Task { @MainActor in toastCenter.show("请允许添加照片权限") }
return
}
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: image)
} completionHandler: { success, error in
Task { @MainActor in
toastCenter.show(success ? "保存成功" : (error?.localizedDescription ?? "保存失败"))
}
}
}
}
}
///
struct PaymentCollectionRecordView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.paymentAPI) private var paymentAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = PaymentCollectionRecordViewModel()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.groups.isEmpty {
PaymentRecordEmptyView()
} else {
PaymentWhiteCard {
VStack(spacing: AppMetrics.Spacing.medium) {
ForEach(viewModel.groups) { group in
VStack(spacing: AppMetrics.Spacing.xSmall) {
PaymentRecordDayHeader(group: group)
ForEach(group.items) { item in
PaymentRecordItemCard(item: item)
}
}
}
}
}
Text("仅支持查看7天的数据")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xB6BECA))
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.bottom, AppMetrics.Spacing.medium)
}
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.top, AppMetrics.Spacing.medium)
}
.background(PaymentCollectionView.pageBackground)
.navigationTitle("收款记录")
.navigationBarTitleDisplayMode(.inline)
.refreshable {
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
.task(id: accountContext.currentScenic?.id) {
await globalLoading.withOptionalLoading(viewModel.groups.isEmpty, message: "加载中...") {
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
}
}
.onChange(of: viewModel.errorMessage) { message in
if let message {
toastCenter.show(message)
}
}
}
}
/// 使 TTS
@MainActor
final class PaymentVoiceSpeaker {
static let shared = PaymentVoiceSpeaker()
private let synthesizer = AVSpeechSynthesizer()
private init() {}
///
func speak(_ text: String) {
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
utterance.rate = 0.5
synthesizer.speak(utterance)
}
}