Add role-based order/deposit lists, AVFoundation QR scanning, verify dialogs, and ViewModel tests so photographers, scenic admins, and store admins match Android behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
11 KiB
Swift
307 lines
11 KiB
Swift
//
|
||
// DateRangePickerViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 通用日期范围选择 Sheet,从 Statistics 抽取,对齐 Android `DateRangeBottomSheet`。
|
||
final class DateRangePickerViewController: UIViewController {
|
||
|
||
var onShortcutSelected: ((String) -> Void)?
|
||
var onRangeSelected: ((Date, Date) -> Void)?
|
||
|
||
private var currentMonth: Date
|
||
private var tempStart: Date?
|
||
private var tempEnd: Date?
|
||
private var selectedShortcut: String?
|
||
|
||
private let shortcutStack = UIStackView()
|
||
private let monthTitleLabel = UILabel()
|
||
private let weekdayStack = UIStackView()
|
||
private let daysCollectionView: UICollectionView
|
||
private var shortcutButtons: [StatisticsPeriodButton] = []
|
||
|
||
private let calendar: Calendar = {
|
||
var calendar = Calendar(identifier: .gregorian)
|
||
calendar.locale = Locale(identifier: "zh_CN")
|
||
calendar.firstWeekday = 2
|
||
return calendar
|
||
}()
|
||
|
||
init(rangeStart: Date?, rangeEnd: Date?) {
|
||
self.tempStart = rangeStart
|
||
self.tempEnd = rangeEnd
|
||
self.currentMonth = rangeStart ?? Date()
|
||
|
||
let layout = UICollectionViewFlowLayout()
|
||
layout.minimumInteritemSpacing = 0
|
||
layout.minimumLineSpacing = 8
|
||
daysCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||
|
||
let today = Calendar.current.startOfDay(for: Date())
|
||
let initCalendar = Calendar(identifier: .gregorian)
|
||
if rangeStart == nil, rangeEnd == nil {
|
||
selectedShortcut = StatisticsRangeShortcut.all.rawValue
|
||
} else if rangeStart == today, rangeEnd == today {
|
||
selectedShortcut = StatisticsRangeShortcut.today.rawValue
|
||
} else if
|
||
let start = rangeStart,
|
||
let end = rangeEnd,
|
||
start == initCalendar.date(byAdding: .day, value: -1, to: today),
|
||
end == initCalendar.date(byAdding: .day, value: -1, to: today) {
|
||
selectedShortcut = StatisticsRangeShortcut.yesterday.rawValue
|
||
} else if
|
||
let start = rangeStart,
|
||
let end = rangeEnd,
|
||
start == initCalendar.date(byAdding: .day, value: -6, to: today),
|
||
end == today {
|
||
selectedShortcut = StatisticsRangeShortcut.last7Days.rawValue
|
||
}
|
||
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .white
|
||
setupUI()
|
||
updateMonthTitle()
|
||
}
|
||
|
||
private func setupUI() {
|
||
shortcutStack.axis = .horizontal
|
||
shortcutStack.distribution = .equalSpacing
|
||
|
||
StatisticsRangeShortcut.allCases.forEach { shortcut in
|
||
let button = StatisticsPeriodButton()
|
||
button.periodTitle = shortcut.rawValue
|
||
button.isSelected = shortcut.rawValue == selectedShortcut
|
||
button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside)
|
||
shortcutButtons.append(button)
|
||
shortcutStack.addArrangedSubview(button)
|
||
}
|
||
|
||
let previousButton = UIButton(type: .system)
|
||
previousButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||
previousButton.tintColor = UIColor(hex: 0x9CA3AF)
|
||
previousButton.addTarget(self, action: #selector(previousMonth), for: .touchUpInside)
|
||
|
||
let nextButton = UIButton(type: .system)
|
||
nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal)
|
||
nextButton.tintColor = UIColor(hex: 0x9CA3AF)
|
||
nextButton.addTarget(self, action: #selector(nextMonth), for: .touchUpInside)
|
||
|
||
monthTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
monthTitleLabel.textAlignment = .center
|
||
|
||
let monthRow = UIStackView(arrangedSubviews: [previousButton, monthTitleLabel, nextButton])
|
||
monthRow.axis = .horizontal
|
||
monthRow.alignment = .center
|
||
monthRow.distribution = .equalCentering
|
||
|
||
weekdayStack.axis = .horizontal
|
||
weekdayStack.distribution = .fillEqually
|
||
["一", "二", "三", "四", "五", "六", "日"].forEach { title in
|
||
let label = UILabel()
|
||
label.text = title
|
||
label.font = .systemFont(ofSize: 12)
|
||
label.textColor = UIColor(hex: 0x9CA3AF)
|
||
label.textAlignment = .center
|
||
weekdayStack.addArrangedSubview(label)
|
||
}
|
||
|
||
daysCollectionView.backgroundColor = .clear
|
||
daysCollectionView.dataSource = self
|
||
daysCollectionView.delegate = self
|
||
daysCollectionView.register(DateCell.self, forCellWithReuseIdentifier: DateCell.reuseIdentifier)
|
||
|
||
view.addSubview(shortcutStack)
|
||
view.addSubview(monthRow)
|
||
view.addSubview(weekdayStack)
|
||
view.addSubview(daysCollectionView)
|
||
|
||
shortcutStack.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
monthRow.snp.makeConstraints { make in
|
||
make.top.equalTo(shortcutStack.snp.bottom).offset(12)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.height.equalTo(32)
|
||
}
|
||
previousButton.snp.makeConstraints { make in
|
||
make.size.equalTo(24)
|
||
}
|
||
nextButton.snp.makeConstraints { make in
|
||
make.size.equalTo(24)
|
||
}
|
||
weekdayStack.snp.makeConstraints { make in
|
||
make.top.equalTo(monthRow.snp.bottom).offset(8)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
daysCollectionView.snp.makeConstraints { make in
|
||
make.top.equalTo(weekdayStack.snp.bottom).offset(8)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||
make.height.equalTo(240)
|
||
}
|
||
}
|
||
|
||
@objc private func shortcutTapped(_ sender: StatisticsPeriodButton) {
|
||
onShortcutSelected?(sender.periodTitle)
|
||
}
|
||
|
||
@objc private func previousMonth() {
|
||
currentMonth = calendar.date(byAdding: .month, value: -1, to: currentMonth) ?? currentMonth
|
||
updateMonthTitle()
|
||
daysCollectionView.reloadData()
|
||
}
|
||
|
||
@objc private func nextMonth() {
|
||
currentMonth = calendar.date(byAdding: .month, value: 1, to: currentMonth) ?? currentMonth
|
||
updateMonthTitle()
|
||
daysCollectionView.reloadData()
|
||
}
|
||
|
||
private func updateMonthTitle() {
|
||
let components = calendar.dateComponents([.year, .month], from: currentMonth)
|
||
monthTitleLabel.text = "\(components.year ?? 0)年\(components.month ?? 0)月"
|
||
}
|
||
|
||
private func monthDates() -> [Date?] {
|
||
guard
|
||
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: currentMonth)),
|
||
let dayRange = calendar.range(of: .day, in: .month, for: monthStart)
|
||
else {
|
||
return []
|
||
}
|
||
|
||
let firstWeekday = calendar.component(.weekday, from: monthStart)
|
||
let leadingEmpty = (firstWeekday + 5) % 7
|
||
var dates: [Date?] = Array(repeating: nil, count: leadingEmpty)
|
||
for day in dayRange {
|
||
if let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) {
|
||
dates.append(date)
|
||
}
|
||
}
|
||
while dates.count % 7 != 0 {
|
||
dates.append(nil)
|
||
}
|
||
return dates
|
||
}
|
||
|
||
private func handleDateTap(_ date: Date) {
|
||
selectedShortcut = nil
|
||
shortcutButtons.forEach { $0.isSelected = false }
|
||
|
||
if tempStart == nil || (tempStart != nil && tempEnd != nil) {
|
||
tempStart = date
|
||
tempEnd = nil
|
||
} else if let start = tempStart {
|
||
if date < start {
|
||
tempEnd = start
|
||
tempStart = date
|
||
} else {
|
||
tempEnd = date
|
||
}
|
||
if let start = tempStart, let end = tempEnd {
|
||
onRangeSelected?(start, end)
|
||
}
|
||
}
|
||
daysCollectionView.reloadData()
|
||
}
|
||
|
||
private func isInRange(_ date: Date) -> Bool {
|
||
guard let start = tempStart else { return false }
|
||
if let end = tempEnd {
|
||
return date >= min(start, end) && date <= max(start, end)
|
||
}
|
||
return calendar.isDate(date, inSameDayAs: start)
|
||
}
|
||
|
||
private final class DateCell: UICollectionViewCell {
|
||
static let reuseIdentifier = "DateRangePickerDateCell"
|
||
|
||
private let label = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
label.textAlignment = .center
|
||
label.font = .systemFont(ofSize: 14)
|
||
contentView.addSubview(label)
|
||
label.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
contentView.layer.cornerRadius = 16
|
||
contentView.clipsToBounds = true
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func configure(text: String?, selected: Bool, inRange: Bool) {
|
||
label.text = text
|
||
if selected {
|
||
contentView.backgroundColor = AppColor.primary
|
||
label.textColor = .white
|
||
} else if inRange {
|
||
contentView.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||
label.textColor = AppColor.primary
|
||
} else {
|
||
contentView.backgroundColor = .clear
|
||
label.textColor = .black
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension DateRangePickerViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||
monthDates().count
|
||
}
|
||
|
||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: DateCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! DateCell
|
||
let dates = monthDates()
|
||
guard indexPath.item < dates.count, let date = dates[indexPath.item] else {
|
||
cell.configure(text: nil, selected: false, inRange: false)
|
||
return cell
|
||
}
|
||
let day = calendar.component(.day, from: date)
|
||
let selected = (tempStart != nil && calendar.isDate(date, inSameDayAs: tempStart!))
|
||
|| (tempEnd != nil && calendar.isDate(date, inSameDayAs: tempEnd!))
|
||
cell.configure(text: "\(day)", selected: selected, inRange: isInRange(date))
|
||
return cell
|
||
}
|
||
|
||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||
let dates = monthDates()
|
||
guard indexPath.item < dates.count, let date = dates[indexPath.item] else { return }
|
||
handleDateTap(date)
|
||
}
|
||
|
||
func collectionView(
|
||
_ collectionView: UICollectionView,
|
||
layout collectionViewLayout: UICollectionViewLayout,
|
||
sizeForItemAt indexPath: IndexPath
|
||
) -> CGSize {
|
||
let width = collectionView.bounds.width / 7
|
||
return CGSize(width: width, height: 32)
|
||
}
|
||
}
|
||
|
||
/// Statistics 模块兼容别名。
|
||
typealias StatisticsDateRangeViewController = DateRangePickerViewController
|