From 9fce6ef7131a8fccd75bffda02360cae2925c9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 26 Aug 2026 14:19:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E6=94=B6=E6=AC=BE=E6=97=A5=E5=8E=86=E4=BA=A4=E4=BA=92=E4=B8=8E?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OfflineCollectionCalendarModel.swift | 28 +++ ...OfflineCollectionDailyViewController.swift | 66 +++++-- ...CollectionRegistrationViewController.swift | 32 +++- .../Views/OfflineCollectionCalendarView.swift | 165 ++++++++++++++---- .../OfflineCollectionFeatureTests.swift | 29 +++ 5 files changed, 262 insertions(+), 58 deletions(-) diff --git a/suixinkan/Features/OfflineCollection/Models/OfflineCollectionCalendarModel.swift b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionCalendarModel.swift index 05245cb..7b12a4e 100644 --- a/suixinkan/Features/OfflineCollection/Models/OfflineCollectionCalendarModel.swift +++ b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionCalendarModel.swift @@ -56,6 +56,28 @@ struct OfflineCollectionCalendarState: Sendable, Equatable { return selectedDate } + /// 判断指定方向是否存在不晚于服务端今日的周或月页面。 + func canMovePage(_ offset: Int) -> Bool { + guard offset != 0 else { return false } + if offset < 0 { return true } + switch mode { + case .week: + guard let candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) else { return false } + let weekday = calendar.component(.weekday, from: candidate) + let daysFromMonday = (weekday + 5) % 7 + let targetMonday = calendar.date(byAdding: .day, value: -daysFromMonday, to: candidate) ?? candidate + return calendar.startOfDay(for: targetMonday) <= maximumDate + case .month: + let current = calendar.dateComponents([.year, .month], from: selectedDate) + guard let monthStart = calendar.date(from: current), + let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) else { return false } + let maximumMonth = calendar.date( + from: calendar.dateComponents([.year, .month], from: maximumDate) + ) ?? maximumDate + return targetMonth <= maximumMonth + } + } + /// 当前周的周一至周日。 var weekDates: [Date] { let weekday = calendar.component(.weekday, from: selectedDate) @@ -74,6 +96,12 @@ struct OfflineCollectionCalendarState: Sendable, Equatable { return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } } + /// 当前选中日期所在周在固定六行月历中的零基行号。 + var selectedWeekRowIndex: Int { + let index = monthDates.firstIndex { calendar.isDate($0, inSameDayAs: selectedDate) } ?? 0 + return index / 7 + } + /// 当前选中月份标题。 var monthTitle: String { let parts = calendar.dateComponents([.year, .month], from: selectedDate) diff --git a/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift b/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift index 3b86487..5e66d3a 100644 --- a/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift +++ b/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift @@ -24,6 +24,8 @@ final class OfflineCollectionDailyViewController: BaseViewController { private let headerContainer = UIView() private let headerStack = UIStackView() private let calendarView = OfflineCollectionCalendarView() + private var calendarHeightConstraint: Constraint? + private var isAnimatingHeaderResize = false private let summaryCard = UIView() private let summaryDateLabel = UILabel() private let totalValue = UILabel() @@ -75,7 +77,7 @@ final class OfflineCollectionDailyViewController: BaseViewController { guard let self else { return } Task { await self.viewModel.selectBusinessDate(date) } } - calendarView.onModeChanged = { [weak self] in self?.resizeHeader() } + calendarView.onModeChanged = { [weak self] animated in self?.resizeHeader(animated: animated) } viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } } } @@ -91,7 +93,9 @@ final class OfflineCollectionDailyViewController: BaseViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - resizeHeader() + if !isAnimatingHeaderResize { + resizeHeader() + } } private func configureTableView() { @@ -116,6 +120,9 @@ final class OfflineCollectionDailyViewController: BaseViewController { make.leading.trailing.equalToSuperview().inset(16) } headerStack.addArrangedSubview(calendarView) + calendarView.snp.makeConstraints { make in + calendarHeightConstraint = make.height.equalTo(calendarView.preferredHeight).constraint + } configureSummaryCard() headerStack.addArrangedSubview(summaryCard) @@ -351,9 +358,41 @@ final class OfflineCollectionDailyViewController: BaseViewController { } } - private func resizeHeader() { + private func resizeHeader(animated: Bool = false) { guard tableView.bounds.width > 0 else { return } + if isAnimatingHeaderResize, !animated { return } headerContainer.frame.size.width = tableView.bounds.width + + let targetCalendarHeight = calendarView.preferredHeight + if animated, + !UIAccessibility.isReduceMotionEnabled, + headerContainer.frame.height > 0, + calendarView.bounds.height > 0 { + let targetHeaderHeight = headerContainer.frame.height + targetCalendarHeight - calendarView.bounds.height + guard abs(headerContainer.frame.height - targetHeaderHeight) > 0.5 else { return } + calendarHeightConstraint?.update(offset: targetCalendarHeight) + isAnimatingHeaderResize = true + UIView.animate( + withDuration: 0.3, + delay: 0, + options: [.curveEaseInOut, .beginFromCurrentState], + animations: { [weak self] in + guard let self else { return } + headerContainer.frame.size.height = targetHeaderHeight + headerContainer.layoutIfNeeded() + tableView.tableHeaderView = headerContainer + tableView.layoutIfNeeded() + }, + completion: { [weak self] _ in + guard let self else { return } + isAnimatingHeaderResize = false + resizeHeader() + } + ) + return + } + + calendarHeightConstraint?.update(offset: targetCalendarHeight) headerContainer.setNeedsLayout() headerContainer.layoutIfNeeded() let height = headerContainer.systemLayoutSizeFitting( @@ -362,8 +401,13 @@ final class OfflineCollectionDailyViewController: BaseViewController { verticalFittingPriority: .fittingSizeLevel ).height guard abs(headerContainer.frame.height - height) > 0.5 else { return } - headerContainer.frame.size.height = height - tableView.tableHeaderView = headerContainer + let updates = { [weak self] in + guard let self else { return } + headerContainer.frame.size.height = height + tableView.tableHeaderView = headerContainer + tableView.layoutIfNeeded() + } + updates() } @MainActor private func presentFeedbackIfNeeded() { @@ -371,15 +415,9 @@ final class OfflineCollectionDailyViewController: BaseViewController { switch viewModel.settlementState { case .idle, .processing: return - case let .success(result): - feedbackPresented = true - let alert = UIAlertController( - title: "补缴成功", - message: "营业日:\(result.date)\n共结清 \(result.updatedCount) 笔", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in self?.finishFeedback() }) - present(alert, animated: true) + case .success: + // 成功后页面数据已经刷新,直接清理反馈状态,不再阻断用户操作。 + viewModel.clearSettlementFeedback() case let .failed(message, canRetry): feedbackPresented = true let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert) diff --git a/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift index bf73b82..9e3b7d6 100644 --- a/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift +++ b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift @@ -370,7 +370,13 @@ final class OfflinePaymentMethodButton: UIControl { private let iconView = UIImageView() private let titleLabel = UILabel() - private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark")) + private let selectionBadge = UIView() + private let checkmarkView = UIImageView( + image: UIImage( + systemName: "checkmark", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold) + ) + ) init(method: OfflineCollectionPaymentMethod) { self.method = method @@ -386,7 +392,7 @@ final class OfflinePaymentMethodButton: UIControl { backgroundColor = selected ? UIColor(hex: 0xF1F7FF) : .white layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor layer.borderWidth = selected ? 1.5 : 1 - checkmarkView.isHidden = !selected + selectionBadge.isHidden = !selected titleLabel.textColor = UIColor(hex: 0x081739) accessibilityTraits = selected ? [.button, .selected] : .button } @@ -400,11 +406,14 @@ final class OfflinePaymentMethodButton: UIControl { titleLabel.text = method.displayName titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) titleLabel.textAlignment = .center + selectionBadge.backgroundColor = UIColor(hex: 0x1684FC) + selectionBadge.layer.cornerRadius = 10 + selectionBadge.layer.borderColor = UIColor.white.cgColor + selectionBadge.layer.borderWidth = 2 + selectionBadge.isHidden = true + selectionBadge.isUserInteractionEnabled = false checkmarkView.tintColor = .white - checkmarkView.backgroundColor = UIColor(hex: 0x1684FC) - checkmarkView.layer.cornerRadius = 11 - checkmarkView.contentMode = .center - checkmarkView.isHidden = true + checkmarkView.contentMode = .scaleAspectFit let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) stack.axis = .vertical @@ -412,12 +421,17 @@ final class OfflinePaymentMethodButton: UIControl { stack.spacing = 12 stack.isUserInteractionEnabled = false addSubview(stack) - addSubview(checkmarkView) + addSubview(selectionBadge) + selectionBadge.addSubview(checkmarkView) stack.snp.makeConstraints { make in make.center.equalToSuperview() } iconView.snp.makeConstraints { make in make.width.height.equalTo(46) } + selectionBadge.snp.makeConstraints { make in + make.width.height.equalTo(20) + make.top.trailing.equalToSuperview().inset(6) + } checkmarkView.snp.makeConstraints { make in - make.width.height.equalTo(22) - make.top.trailing.equalToSuperview().inset(-2) + make.center.equalToSuperview() + make.width.height.equalTo(11) } setSelected(false) } diff --git a/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift index 567eb0f..9f04286 100644 --- a/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift +++ b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift @@ -5,10 +5,11 @@ import UIKit @MainActor final class OfflineCollectionCalendarView: UIView { var onDateSelected: ((String) -> Void)? - var onModeChanged: (() -> Void)? + var onModeChanged: ((_ animated: Bool) -> Void)? private let titleLabel = UILabel() private let toggleButton = UIButton(type: .system) + private let previousButton = UIButton(type: .system) private let nextButton = UIButton(type: .system) private let rowsStack = UIStackView() private var rowStacks: [UIStackView] = [] @@ -16,9 +17,15 @@ final class OfflineCollectionCalendarView: UIView { private var pendingDates: Set = [] private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date()) private let calendar = OfflineCollectionDate.calendar + private var isAnimatingModeChange = false + + /// 当前周/月模式期望占用的固定高度,供外层表头同步执行高度动画。 + var preferredHeight: CGFloat { + state.mode == .week ? 160 : 378 + } override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: state.mode == .week ? 160 : 378) + CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight) } override init(frame: CGRect) { @@ -64,14 +71,11 @@ final class OfflineCollectionCalendarView: UIView { toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside) toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle" - nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) - nextButton.tintColor = UIColor(hex: 0x081739) - nextButton.accessibilityLabel = "下一页" - nextButton.addTarget(self, action: #selector(nextPage), for: .touchUpInside) - nextButton.snp.makeConstraints { make in make.width.height.equalTo(44) } + configureNavigationButton(previousButton, imageName: "chevron.left", action: #selector(previousPage)) + configureNavigationButton(nextButton, imageName: "chevron.right", action: #selector(nextPage)) let spacer = UIView() - let header = UIStackView(arrangedSubviews: [titleLabel, spacer, toggleButton, nextButton]) + let header = UIStackView(arrangedSubviews: [titleLabel, spacer, previousButton, toggleButton, nextButton]) header.axis = .horizontal header.alignment = .center header.spacing = 8 @@ -134,14 +138,31 @@ final class OfflineCollectionCalendarView: UIView { reloadDates() } + private func configureNavigationButton(_ button: UIButton, imageName: String, action: Selector) { + button.setImage(UIImage(systemName: imageName), for: .normal) + button.tintColor = UIColor(hex: 0x081739) + button.addTarget(self, action: action, for: .touchUpInside) + button.snp.makeConstraints { make in make.width.height.equalTo(44) } + } + private func reloadDates() { - titleLabel.text = state.monthTitle + UIView.performWithoutAnimation { + titleLabel.text = state.monthTitle + titleLabel.layoutIfNeeded() + } let isMonth = state.mode == .month toggleButton.configuration?.title = isMonth ? "周" : "月" toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down") - rowStacks.enumerated().forEach { $0.element.isHidden = !isMonth && $0.offset > 0 } + toggleButton.accessibilityLabel = isMonth ? "切换为周历" : "切换为月历" + previousButton.accessibilityLabel = isMonth ? "上一月" : "上一周" + nextButton.accessibilityLabel = isMonth ? "下一月" : "下一周" + nextButton.isEnabled = state.canMovePage(1) && !isAnimatingModeChange + nextButton.alpha = nextButton.isEnabled ? 1 : 0.35 + previousButton.isEnabled = !isAnimatingModeChange + previousButton.alpha = previousButton.isEnabled ? 1 : 0.35 + toggleButton.isEnabled = !isAnimatingModeChange - let dates = isMonth ? state.monthDates : state.weekDates + let dates = state.monthDates let selectedMonth = calendar.component(.month, from: state.selectedDate) for (index, button) in dayButtons.enumerated() { guard index < dates.count else { @@ -162,19 +183,58 @@ final class OfflineCollectionCalendarView: UIView { calendar: calendar ) } + if !isAnimatingModeChange { + applyRowVisibility(expanded: isMonth, selectedRow: state.selectedWeekRowIndex) + } invalidateIntrinsicContentSize() } + private func applyRowVisibility(expanded: Bool, selectedRow: Int) { + for (index, row) in rowStacks.enumerated() { + let visible = expanded || index == selectedRow + row.isHidden = !visible + row.alpha = visible ? 1 : 0 + } + } + @objc private func toggleMode() { + guard !isAnimatingModeChange else { return } + let selectedRow = state.selectedWeekRowIndex state.toggleMode() - let animations = { [weak self] in - self?.reloadDates() - self?.superview?.layoutIfNeeded() + let expanded = state.mode == .month + let animated = !UIAccessibility.isReduceMotionEnabled + + guard animated else { + reloadDates() + applyRowVisibility(expanded: expanded, selectedRow: selectedRow) + invalidateIntrinsicContentSize() + onModeChanged?(false) + return } - if UIAccessibility.isReduceMotionEnabled { animations() } else { - UIView.animate(withDuration: 0.2, animations: animations) + + isAnimatingModeChange = true + rowsStack.isUserInteractionEnabled = false + reloadDates() + invalidateIntrinsicContentSize() + onModeChanged?(true) + + let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { [weak self] in + guard let self else { return } + for (index, row) in rowStacks.enumerated() where index != selectedRow { + row.isHidden = !expanded + row.alpha = expanded ? 1 : 0 + } + layoutIfNeeded() + superview?.layoutIfNeeded() } - onModeChanged?() + animator.addCompletion { [weak self] _ in + guard let self else { return } + isAnimatingModeChange = false + rowsStack.isUserInteractionEnabled = true + applyRowVisibility(expanded: expanded, selectedRow: selectedRow) + reloadDates() + } + animator.startAnimation() } @objc private func dayTapped(_ sender: OfflineCollectionDayButton) { @@ -184,21 +244,31 @@ final class OfflineCollectionCalendarView: UIView { } @objc private func swiped(_ gesture: UISwipeGestureRecognizer) { - let oldDate = state.selectedDate let offset = gesture.direction == .left ? 1 : -1 - let date = state.movePage(offset) - guard !calendar.isDate(oldDate, inSameDayAs: date) else { return } - let transition: UIView.AnimationOptions = gesture.direction == .left ? .transitionCrossDissolve : .transitionCrossDissolve - if UIAccessibility.isReduceMotionEnabled { reloadDates() } else { - UIView.transition(with: rowsStack, duration: 0.18, options: transition) { [weak self] in self?.reloadDates() } - } - onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) + movePage(offset) + } + + @objc private func previousPage() { + movePage(-1) } @objc private func nextPage() { + movePage(1) + } + + private func movePage(_ offset: Int) { + guard !isAnimatingModeChange, state.canMovePage(offset) else { return } let oldDate = state.selectedDate - let date = state.movePage(1) + let date = state.movePage(offset) guard !calendar.isDate(oldDate, inSameDayAs: date) else { return } + if !UIAccessibility.isReduceMotionEnabled { + let transition = CATransition() + transition.duration = 0.22 + transition.type = .push + transition.subtype = offset > 0 ? .fromRight : .fromLeft + transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + rowsStack.layer.add(transition, forKey: "offlineCollection.calendar.page") + } reloadDates() onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) } @@ -206,20 +276,30 @@ final class OfflineCollectionCalendarView: UIView { /// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。 private final class OfflineCollectionDayButton: UIControl { + private let stateBackgroundView = UIView() private let dayLabel = UILabel() private let pendingDot = UIView() private(set) var date: Date? override init(frame: CGRect) { super.init(frame: frame) - layer.cornerRadius = 10 + stateBackgroundView.isUserInteractionEnabled = false + stateBackgroundView.layer.cornerRadius = 11 dayLabel.font = .systemFont(ofSize: 17, weight: .medium) dayLabel.textAlignment = .center - pendingDot.layer.cornerRadius = 2.5 + pendingDot.layer.cornerRadius = 3.5 + addSubview(stateBackgroundView) addSubview(dayLabel) addSubview(pendingDot) - dayLabel.snp.makeConstraints { make in make.centerX.equalToSuperview(); make.centerY.equalToSuperview().offset(-2) } - pendingDot.snp.makeConstraints { make in make.top.equalTo(dayLabel.snp.bottom).offset(2); make.centerX.equalToSuperview(); make.width.height.equalTo(5) } + stateBackgroundView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(36) + } + dayLabel.snp.makeConstraints { make in make.center.equalTo(stateBackgroundView) } + pendingDot.snp.makeConstraints { make in + make.top.trailing.equalTo(stateBackgroundView) + make.width.height.equalTo(7) + } snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) } } @@ -239,12 +319,27 @@ private final class OfflineCollectionDayButton: UIControl { self.date = date isEnabled = enabled dayLabel.text = String(calendar.component(.day, from: date)) + let warningColor = UIColor(hex: 0xFF7600) pendingDot.isHidden = !pending - pendingDot.backgroundColor = selected ? .white : AppColor.danger - backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF1F0) : .clear) - layer.borderWidth = pending && selected ? 2 : (today && !selected ? 1 : 0) - layer.borderColor = (pending && selected ? AppColor.danger : AppColor.primary).cgColor - dayLabel.textColor = selected ? .white : (enabled ? (inCurrentMonth ? AppColor.textPrimary : AppColor.textTertiary) : AppColor.textTertiary) + pendingDot.backgroundColor = warningColor + pendingDot.layer.borderWidth = selected ? 1.5 : 0 + pendingDot.layer.borderColor = UIColor.white.cgColor + backgroundColor = .clear + stateBackgroundView.backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF0E2) : .clear) + stateBackgroundView.layer.cornerRadius = today && !selected && !pending ? 18 : 11 + stateBackgroundView.layer.borderWidth = today && !selected ? 1 : 0 + stateBackgroundView.layer.borderColor = AppColor.primary.cgColor + if selected { + dayLabel.textColor = .white + } else if !enabled || !inCurrentMonth { + dayLabel.textColor = AppColor.textTertiary + } else if pending { + dayLabel.textColor = warningColor + } else if today { + dayLabel.textColor = AppColor.primary + } else { + dayLabel.textColor = AppColor.textPrimary + } alpha = enabled ? 1 : 0.35 accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")" accessibilityTraits = selected ? [.button, .selected] : .button diff --git a/suixinkanTests/OfflineCollectionFeatureTests.swift b/suixinkanTests/OfflineCollectionFeatureTests.swift index 98ab826..b391bc5 100644 --- a/suixinkanTests/OfflineCollectionFeatureTests.swift +++ b/suixinkanTests/OfflineCollectionFeatureTests.swift @@ -87,6 +87,35 @@ final class OfflineCollectionCalendarTests: XCTestCase { state.movePage(40) XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-25") } + + func testCalendarPageNavigationKeepsPositionAndUsesTodayAtFutureBoundary() throws { + let maximum = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-26")) + let friday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-21")) + var week = OfflineCollectionCalendarState(selectedDate: friday, maximumDate: maximum) + XCTAssertTrue(week.canMovePage(1)) + week.movePage(1) + XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-26") + XCTAssertFalse(week.canMovePage(1)) + + let tuesday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25")) + week = OfflineCollectionCalendarState(selectedDate: tuesday, maximumDate: maximum) + week.movePage(-1) + XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-18") + + let march = try XCTUnwrap(OfflineCollectionDate.date(from: "2025-03-31")) + var month = OfflineCollectionCalendarState(selectedDate: march, maximumDate: maximum, mode: .month) + month.movePage(-1) + XCTAssertEqual(OfflineCollectionDate.businessDate(for: month.selectedDate), "2025-02-28") + } + + func testSelectedWeekRowIndexAndModeTogglePreserveDate() throws { + let selected = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-12")) + var state = OfflineCollectionCalendarState(selectedDate: selected, maximumDate: selected) + XCTAssertEqual(state.selectedWeekRowIndex, 2) + state.toggleMode() + XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-12") + XCTAssertEqual(state.selectedWeekRowIndex, 2) + } } /// 线下收款 ViewModel 的并发与幂等行为测试。