Files
suixinkan_uikit/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift

599 lines
20 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.

//
// WalletViewModels.swift
// suixinkan
//
import Foundation
@MainActor
///
protocol WalletProfileServing {
///
func realNameInfo() async throws -> RealNameInfoResponse
///
func bankCardInfo() async throws -> BankCardInfoResponse
}
extension ProfileAPI: WalletProfileServing {}
/// Tab
enum WalletTab: Int, CaseIterable {
case withdraw
case transaction
///
var title: String {
switch self {
case .withdraw: "提现记录"
case .transaction: "收益明细"
}
}
}
///
enum WalletFilter: Int, CaseIterable {
case last7Days
case last30Days
case last180Days
///
var title: String {
switch self {
case .last7Days: "最近7天"
case .last30Days: "最近30天"
case .last180Days: "最近180天"
}
}
///
var dayOffset: Int {
switch self {
case .last7Days: 7
case .last30Days: 30
case .last180Days: 180
}
}
}
///
enum WalletWithdrawDestination: Equatable {
case realNameAuth
case realNameAudit(RealNameInfo)
case realNamePending
case withdrawalSettings
case withdrawalAudit(BankCardInfo)
case bankCardPending
case withdraw
}
/// ViewModel
final class WalletViewModel {
private(set) var summary: WalletSummaryResponse?
private(set) var selectedTab: WalletTab = .withdraw
private(set) var filter: WalletFilter = .last7Days
private(set) var withdrawRecords: [WalletWithdrawItem] = []
private(set) var transactionGroups: [EarningDetailGroup] = []
private(set) var totalIncomeLabel = "¥ 0.00"
private(set) var totalPointsLabel = 0
private(set) var availablePoints = 0
private(set) var summaryLoading = false
private(set) var withdrawLoading = false
private(set) var withdrawRefreshing = false
private(set) var transactionLoading = false
private(set) var transactionRefreshing = false
private(set) var pointsLoading = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
private let staffIdProvider: () -> Int?
private let calendar: Calendar
private var withdrawPage = 1
private var withdrawCanLoadMore = true
private var transactionPage = 1
private var transactionCanLoadMore = true
private let pageSize = 10
/// ViewModel
init(
staffIdProvider: @escaping () -> Int? = {
Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines))
},
calendar: Calendar = Calendar(identifier: .gregorian)
) {
self.staffIdProvider = staffIdProvider
self.calendar = calendar
}
///
func loadInitial(api: any WalletPageServing) async {
await refreshSummary(api: api)
await refreshWithdraw(api: api)
await refreshTransaction(api: api)
await refreshPoints(api: api)
}
///
func refreshSummary(api: any WalletPageServing) async {
summaryLoading = true
errorMessage = nil
notifyStateChange()
do {
summary = try await api.walletSummary(type: 0)
} catch {
errorMessage = error.localizedDescription
}
summaryLoading = false
notifyStateChange()
}
///
func refreshPoints(api: any WalletPageServing) async {
guard let staffId = staffIdProvider(), staffId > 0 else { return }
pointsLoading = true
notifyStateChange()
do {
let overview = try await api.pointOverview(staffId: staffId)
availablePoints = overview.availablePoints
} catch {
errorMessage = error.localizedDescription
}
pointsLoading = false
notifyStateChange()
}
/// Tab
func selectTab(_ tab: WalletTab, api: any WalletPageServing) async {
guard tab != selectedTab else { return }
selectedTab = tab
notifyStateChange()
switch tab {
case .withdraw:
if withdrawRecords.isEmpty { await refreshWithdraw(api: api) }
case .transaction:
if transactionGroups.isEmpty { await refreshTransaction(api: api) }
}
}
///
func selectFilter(_ nextFilter: WalletFilter, api: any WalletPageServing) async {
guard nextFilter != filter else { return }
filter = nextFilter
notifyStateChange()
await refreshTransaction(api: api)
}
///
func refreshWithdraw(api: any WalletPageServing) async {
await loadWithdraw(api: api, refresh: true)
}
///
func loadMoreWithdrawIfNeeded(currentIndex: Int, api: any WalletPageServing) async {
guard currentIndex >= withdrawRecords.count - 2, withdrawCanLoadMore, !withdrawLoading else { return }
await loadWithdraw(api: api, refresh: false)
}
///
func refreshTransaction(api: any WalletPageServing) async {
await loadTransaction(api: api, refresh: true)
}
///
func loadMoreTransactionIfNeeded(currentIndex: Int, api: any WalletPageServing) async {
let count = transactionEntries.count
guard currentIndex >= count - 2, transactionCanLoadMore, !transactionLoading else { return }
await loadTransaction(api: api, refresh: false)
}
///
var transactionEntries: [WalletTransactionEntry] {
transactionGroups.flatMap { group -> [WalletTransactionEntry] in
[.header(group)] + group.items.map(WalletTransactionEntry.item)
}
}
///
func resolveWithdrawDestination(profileAPI: any WalletProfileServing) async throws -> WalletWithdrawDestination {
let realName = try await profileAPI.realNameInfo().realNameInfo
guard let realName else { return .realNameAuth }
switch realName.auditStatus {
case 2:
break
case 3:
return .realNameAudit(realName)
default:
return .realNamePending
}
let bankCard = try await profileAPI.bankCardInfo().bankCard
guard let bankCard else { return .withdrawalSettings }
switch bankCard.auditStatus {
case 2:
return .withdraw
case 3:
return .withdrawalAudit(bankCard)
default:
return .bankCardPending
}
}
private func loadWithdraw(api: any WalletPageServing, refresh: Bool) async {
if withdrawLoading && !refresh { return }
let targetPage = refresh ? 1 : withdrawPage
withdrawLoading = true
withdrawRefreshing = refresh
if refresh {
withdrawCanLoadMore = true
withdrawPage = 1
}
notifyStateChange()
do {
let response = try await api.walletWithdrawList(page: targetPage, pageSize: pageSize)
withdrawRecords = refresh ? response.item : withdrawRecords + response.item
withdrawCanLoadMore = withdrawRecords.count < response.total
withdrawPage = response.item.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
withdrawRecords = []
withdrawCanLoadMore = false
}
errorMessage = error.localizedDescription
}
withdrawLoading = false
withdrawRefreshing = false
notifyStateChange()
}
private func loadTransaction(api: any WalletPageServing, refresh: Bool) async {
if transactionLoading && !refresh { return }
let targetPage = refresh ? 1 : transactionPage
let range = dateRange(for: filter)
transactionLoading = true
transactionRefreshing = refresh
if refresh {
transactionCanLoadMore = true
transactionPage = 1
}
notifyStateChange()
do {
let response = try await api.earningDetail(
startDate: range.start,
endDate: range.end,
page: targetPage,
pageSize: pageSize
)
transactionGroups = refresh ? response.list : transactionGroups + response.list
totalIncomeLabel = Self.formatAmount(response.totalAmount)
totalPointsLabel = response.totalPoints
let itemCount = transactionGroups.reduce(0) { $0 + $1.items.count }
transactionCanLoadMore = itemCount < response.total
transactionPage = response.list.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
transactionGroups = []
transactionCanLoadMore = false
}
errorMessage = error.localizedDescription
}
transactionLoading = false
transactionRefreshing = false
notifyStateChange()
}
private func dateRange(for filter: WalletFilter, now: Date = Date()) -> (start: String, end: String) {
let end = calendar.startOfDay(for: now)
let start = calendar.date(byAdding: .day, value: -filter.dayOffset, to: end) ?? end
return (Self.dayFormatter.string(from: start), Self.dayFormatter.string(from: end))
}
private func notifyStateChange() {
onStateChange?()
}
///
static func formatAmount(_ value: String?) -> String {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return "¥ 0.00"
}
guard let decimal = Decimal(string: value) else { return "¥ \(value)" }
let number = NSDecimalNumber(decimal: decimal)
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.roundingMode = .halfUp
return "¥ \(formatter.string(from: number) ?? value)"
}
private static let dayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
}
///
enum WalletTransactionEntry: Hashable {
case header(EarningDetailGroup)
case item(EarningDetailItem)
}
/// ViewModel
final class WithdrawViewModel {
private(set) var withdrawInfo: WithdrawInfoResponse?
private(set) var amount = ""
private(set) var verificationCode = ""
private(set) var countdown = 0
private(set) var loading = false
private(set) var submitting = false
private(set) var statusMessage: String?
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
///
func load(api: any WalletPageServing) async {
loading = true
errorMessage = nil
notifyStateChange()
do {
withdrawInfo = try await api.withdrawInfo()
} catch {
errorMessage = "获取提现信息失败:\(error.localizedDescription)"
}
loading = false
notifyStateChange()
}
///
func updateAmount(_ value: String) {
amount = Self.normalizedMoneyInput(value, previous: amount)
notifyStateChange()
}
///
func withdrawAll() {
amount = withdrawInfo?.amountWithdrawable ?? ""
notifyStateChange()
}
///
func updateVerificationCode(_ value: String) {
verificationCode = value.filter(\.isNumber)
notifyStateChange()
}
///
func requestVerificationCode(api: any WalletPageServing) async {
guard countdown == 0 else { return }
do {
try await api.withdrawSendSMS()
statusMessage = "验证码发送成功"
countdown = 60
} catch {
errorMessage = "验证码发送失败:\(error.localizedDescription)"
}
notifyStateChange()
}
///
func decrementCountdown() {
guard countdown > 0 else { return }
countdown -= 1
notifyStateChange()
}
///
func submit(api: any WalletPageServing) async -> Bool {
if let validationMessage {
errorMessage = validationMessage
notifyStateChange()
return false
}
submitting = true
notifyStateChange()
do {
try await api.withdrawApply(amount: amount, smsCode: verificationCode)
statusMessage = "已提交申请"
submitting = false
notifyStateChange()
return true
} catch {
errorMessage = error.localizedDescription
submitting = false
notifyStateChange()
return false
}
}
///
var canSubmit: Bool {
validationMessage == nil && !submitting
}
///
var validationMessage: String? {
guard let withdrawInfo else { return "提现信息加载中" }
guard !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入提现金额" }
guard let amountDecimal = Decimal(string: amount), amountDecimal > 0 else { return "请输入有效的金额" }
let withdrawable = Decimal(string: withdrawInfo.amountWithdrawable) ?? 0
if amountDecimal > withdrawable { return "金额不能大于可提现金额" }
guard !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入验证码" }
return nil
}
///
static func normalizedMoneyInput(_ value: String, previous: String) -> String {
var result = ""
var hasDot = false
var decimalCount = 0
for character in value {
if character.isNumber {
if hasDot {
guard decimalCount < 2 else { continue }
decimalCount += 1
}
result.append(character)
} else if character == ".", !hasDot {
hasDot = true
if result.isEmpty { result = "0" }
result.append(character)
}
}
while result.count > 1, result.first == "0", result.dropFirst().first?.isNumber == true {
result.removeFirst()
}
return result
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class PointsRedemptionViewModel {
private(set) var overview: PointOverviewResponse?
private(set) var withdrawnPoints = 0
private(set) var pointsInput = ""
private(set) var withdrawRecords: [PointWithdrawItem] = []
private(set) var overviewLoading = false
private(set) var withdrawLoading = false
private(set) var withdrawRefreshing = false
private(set) var statusMessage: String?
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
private let staffIdProvider: () -> Int?
private var withdrawPage = 1
private var withdrawCanLoadMore = true
private let pageSize = 10
/// ViewModel
init(staffIdProvider: @escaping () -> Int? = {
Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines))
}) {
self.staffIdProvider = staffIdProvider
}
///
func loadInitial(api: any WalletPageServing) async {
await refreshOverview(api: api)
await refreshWithdrawList(api: api)
}
///
func refreshOverview(api: any WalletPageServing) async {
guard let staffId = staffIdProvider(), staffId > 0 else { return }
overviewLoading = true
notifyStateChange()
do {
let response = try await api.pointOverview(staffId: staffId)
overview = response
withdrawnPoints = response.withdrawnPoints
} catch {
errorMessage = error.localizedDescription
}
overviewLoading = false
notifyStateChange()
}
///
func refreshWithdrawList(api: any WalletPageServing) async {
await loadWithdrawList(api: api, refresh: true)
}
///
func loadMoreWithdrawListIfNeeded(currentIndex: Int, api: any WalletPageServing) async {
guard currentIndex >= withdrawRecords.count - 2, withdrawCanLoadMore, !withdrawLoading else { return }
await loadWithdrawList(api: api, refresh: false)
}
///
func updatePointsInput(_ value: String) {
let digits = value.filter(\.isNumber)
guard !digits.isEmpty else {
pointsInput = ""
notifyStateChange()
return
}
let points = min(Int(digits) ?? 0, withdrawnPoints)
pointsInput = points > 0 ? String(points) : ""
notifyStateChange()
}
///
func withdrawAll() {
pointsInput = String(withdrawnPoints)
notifyStateChange()
}
///
func applyWithdraw(api: any WalletPageServing) async {
let points = Int(pointsInput) ?? 0
guard points > 0 else {
errorMessage = "请输入提现积分"
notifyStateChange()
return
}
guard points <= withdrawnPoints else {
errorMessage = "提现积分不能大于可提现积分"
notifyStateChange()
return
}
do {
try await api.pointWithdrawApply(points: points, remark: "积分提现申请")
statusMessage = "提现申请成功"
pointsInput = ""
notifyStateChange()
await refreshOverview(api: api)
await refreshWithdrawList(api: api)
} catch {
errorMessage = error.localizedDescription.isEmpty ? "提现申请失败" : error.localizedDescription
notifyStateChange()
}
}
private func loadWithdrawList(api: any WalletPageServing, refresh: Bool) async {
if withdrawLoading && !refresh { return }
let targetPage = refresh ? 1 : withdrawPage
withdrawLoading = true
withdrawRefreshing = refresh
if refresh {
withdrawCanLoadMore = true
withdrawPage = 1
}
notifyStateChange()
do {
let response = try await api.pointWithdrawList(status: nil, page: targetPage, pageSize: pageSize)
withdrawRecords = refresh ? response.list : withdrawRecords + response.list
withdrawCanLoadMore = withdrawRecords.count < response.total
withdrawPage = response.list.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
withdrawRecords = []
withdrawCanLoadMore = false
}
errorMessage = error.localizedDescription
}
withdrawLoading = false
withdrawRefreshing = false
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}