From 87fba3d5d64746cfc3ce774adaeb693e0137602e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 8 Jul 2026 11:00:44 +0800 Subject: [PATCH] Sync OTG transfer photo list UI --- .../Models/TravelAlbumOTGTransferModels.swift | 145 +++++ .../WiredCameraTransferViewModel.swift | 76 ++- .../WiredCameraTransferViewController.swift | 585 +++++++++++++++--- suixinkanTests/TravelAlbumAPITests.swift | 2 +- .../TravelAlbumViewModelTests.swift | 122 ++++ 5 files changed, 839 insertions(+), 91 deletions(-) diff --git a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift index ca45afc..731bc78 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift @@ -31,6 +31,27 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable { } } +/// OTG 传输页半小时维度时间槽。 +struct TravelAlbumOTGTimeSlot: Hashable, Sendable { + let id: String + let dateLabel: String + let slotStartLabel: String + let photoCount: Int +} + +/// OTG 传输页左侧时间导航日期分组。 +struct TravelAlbumOTGDateGroup: Hashable, Sendable { + let dateLabel: String + let slots: [TravelAlbumOTGTimeSlot] +} + +/// OTG 传输页照片列表半小时分段。 +struct TravelAlbumOTGPhotoSection: Hashable, Sendable { + let slotId: String + let headerTitle: String + let photos: [TravelAlbumOTGPhotoItem] +} + /// OTG 传输页 Tab。 enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { case all @@ -46,6 +67,130 @@ enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { } } +extension TravelAlbumOTGPhotoItem { + /// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。 + func capturedDate() -> Date? { + TravelAlbumOTGTimeGrouping.capturedDate(from: capturedAt) + } +} + +extension Array where Element == TravelAlbumOTGPhotoItem { + /// 按 Android OTG 页规则构造半小时照片分段。 + func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] { + let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in + guard let date = photo.capturedDate() else { return nil } + return (TravelAlbumOTGTimeGrouping.halfHourSlotStart(for: date), photo) + } + let grouped = Dictionary(grouping: pairs, by: \.slotStart) + return grouped.keys + .sorted(by: >) + .map { slotStart in + let photos = (grouped[slotStart] ?? []) + .map(\.photo) + .sorted { lhs, rhs in + (lhs.capturedDate() ?? .distantPast) > (rhs.capturedDate() ?? .distantPast) + } + return TravelAlbumOTGPhotoSection( + slotId: TravelAlbumOTGTimeGrouping.slotId(for: slotStart), + headerTitle: TravelAlbumOTGTimeGrouping.sectionHeaderTitle(for: slotStart), + photos: photos + ) + } + } + + /// 按 Android OTG 页规则构造左侧时间快速导航分组。 + func buildSidebarGroups() -> [TravelAlbumOTGDateGroup] { + var slotCounts: [Date: Int] = [:] + for photo in self { + guard let date = photo.capturedDate() else { continue } + let slotStart = TravelAlbumOTGTimeGrouping.halfHourSlotStart(for: date) + slotCounts[slotStart, default: 0] += 1 + } + let groupedByDay = Dictionary(grouping: slotCounts.keys) { slotStart in + TravelAlbumOTGTimeGrouping.dayStart(for: slotStart) + } + return groupedByDay.keys + .sorted(by: >) + .map { dayStart in + let slots = (groupedByDay[dayStart] ?? []) + .sorted(by: >) + .map { slotStart in + TravelAlbumOTGTimeSlot( + id: TravelAlbumOTGTimeGrouping.slotId(for: slotStart), + dateLabel: TravelAlbumOTGTimeGrouping.dateLabel(for: slotStart), + slotStartLabel: TravelAlbumOTGTimeGrouping.timeLabel(for: slotStart), + photoCount: slotCounts[slotStart] ?? 0 + ) + } + return TravelAlbumOTGDateGroup( + dateLabel: TravelAlbumOTGTimeGrouping.dateLabel(for: dayStart), + slots: slots + ) + } + } +} + +/// OTG 传输页时间分组格式化工具,对齐 Android 的半小时槽规则。 +enum TravelAlbumOTGTimeGrouping { + private static let calendar = Calendar(identifier: .gregorian) + + /// 解析 Android OTG 页面使用的拍摄时间字符串。 + static func capturedDate(from text: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter.date(from: text) + } + + /// 计算半小时槽起点。 + static func halfHourSlotStart(for date: Date) -> Date { + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + let minute = ((components.minute ?? 0) / 30) * 30 + return calendar.date( + from: DateComponents( + year: components.year, + month: components.month, + day: components.day, + hour: components.hour, + minute: minute + ) + ) ?? date + } + + /// 计算日期起点。 + static func dayStart(for date: Date) -> Date { + calendar.startOfDay(for: date) + } + + /// 时间槽唯一标识。 + static func slotId(for date: Date) -> String { + format(date, pattern: "yyyy-MM-dd'T'HH:mm", locale: Locale(identifier: "en_US_POSIX")) + } + + /// 主列表分段标题。 + static func sectionHeaderTitle(for date: Date) -> String { + let end = calendar.date(byAdding: .minute, value: 30, to: date) ?? date + return "\(dateLabel(for: date)) \(timeLabel(for: date)) - \(timeLabel(for: end))" + } + + /// 日期标签。 + static func dateLabel(for date: Date) -> String { + format(date, pattern: "MM月dd日", locale: Locale(identifier: "zh_CN")) + } + + /// 时间标签。 + static func timeLabel(for date: Date) -> String { + format(date, pattern: "HH:mm", locale: Locale(identifier: "en_US_POSIX")) + } + + private static func format(_ date: Date, pattern: String, locale: Locale) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.dateFormat = pattern + return formatter.string(from: date) + } +} + /// 相机照片格式匹配工具,对齐 Android `OtgPhotoFormatMatcher`。 enum TravelAlbumOTGPhotoFormatMatcher { /// 相机照片类型。 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 1097cb6..96ceca5 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -17,10 +17,16 @@ final class WiredCameraTransferViewModel { didSet { notifyStateChanged() } } private(set) var photos: [TravelAlbumOTGPhotoItem] = [] { - didSet { notifyStateChanged() } + didSet { + normalizeSelectedTimeSlot() + notifyStateChanged() + } } private(set) var selectedTab: TravelAlbumOTGTransferTab = .all { - didSet { notifyStateChanged() } + didSet { + normalizeSelectedTimeSlot() + notifyStateChanged() + } } private(set) var selectedPhotoIds: Set = [] { didSet { notifyStateChanged() } @@ -28,6 +34,12 @@ final class WiredCameraTransferViewModel { private(set) var selectUploadMode = false { didSet { notifyStateChanged() } } + private(set) var sidebarExpanded = true { + didSet { notifyStateChanged() } + } + private(set) var selectedTimeSlotId: String? { + didSet { notifyStateChanged() } + } private(set) var sonyMTPHint: String? private(set) var isContentCatalogReady = false @@ -135,6 +147,32 @@ final class WiredCameraTransferViewModel { } } + /// 过滤后照片的半小时时间分段。 + var photoSections: [TravelAlbumOTGPhotoSection] { + filteredPhotos().buildPhotoSections() + } + + /// 过滤后照片的左侧时间导航分组。 + var sidebarGroups: [TravelAlbumOTGDateGroup] { + filteredPhotos().buildSidebarGroups() + } + + /// 当前可见列表中可被选择上传的照片 ID。 + var selectableVisiblePhotoIds: Set { + Set(filteredPhotos().filter(\.canSelectForUpload).map(\.id)) + } + + /// 当前可见列表中是否已全选可上传照片。 + var allVisibleSelectablePhotosSelected: Bool { + let ids = selectableVisiblePhotoIds + return !ids.isEmpty && ids.isSubset(of: selectedPhotoIds) + } + + /// 当前可见列表中已选择的可上传照片数量。 + var selectedVisibleUploadCount: Int { + selectedPhotoIds.intersection(selectableVisiblePhotoIds).count + } + /// 绑定连接管理器并启动发现。 func start() { connectionManager.configureLiveTransfer(albumID: albumId) @@ -178,6 +216,17 @@ final class WiredCameraTransferViewModel { selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id))) } + /// 展开或收起左侧时间快速导航。 + func toggleSidebar() { + sidebarExpanded.toggle() + } + + /// 选择左侧时间槽,并联动主列表滚动。 + func selectTimeSlot(id: String) { + guard photoSections.contains(where: { $0.slotId == id }) else { return } + selectedTimeSlotId = id + } + /// 切换传输模式。 func selectTransferMode(_ option: String) { guard option == "边拍边传" || option == "拍后传输" else { return } @@ -252,6 +301,18 @@ final class WiredCameraTransferViewModel { } } + /// 切换当前可见照片的全选状态。 + func toggleVisibleSelectablePhotos() { + guard selectUploadMode else { return } + let ids = selectableVisiblePhotoIds + guard !ids.isEmpty else { return } + if ids.isSubset(of: selectedPhotoIds) { + selectedPhotoIds.subtract(ids) + } else { + selectedPhotoIds.formUnion(ids) + } + } + /// 上传选中照片。 func uploadSelectedTransferPhotos() { let ids = selectedPhotoIds @@ -303,6 +364,17 @@ final class WiredCameraTransferViewModel { photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt } } + private func normalizeSelectedTimeSlot() { + let sections = filteredPhotos().buildPhotoSections() + guard let firstSlotId = sections.first?.slotId else { + selectedTimeSlotId = nil + return + } + if selectedTimeSlotId == nil || !sections.contains(where: { $0.slotId == selectedTimeSlotId }) { + selectedTimeSlotId = firstSlotId + } + } + private func startUploadByIds(_ ids: Set, emptyMessage: String) { guard !isUploading else { showMessage("正在上传,请稍候") diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index 62292a5..908f9fc 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -7,6 +7,23 @@ import Kingfisher import SnapKit import UIKit +private enum WiredTransferMetrics { + static let sidebarWidth: CGFloat = 76 + static let rowHeight: CGFloat = 66 + static let sectionHeaderHeight: CGFloat = 28 + static let selectAllBarHeight: CGFloat = 44 +} + +private struct WiredTransferListSection: Hashable { + let id: String + let title: String + + init(_ section: TravelAlbumOTGPhotoSection) { + id = section.slotId + title = section.headerTitle + } +} + /// 有线相机传输页,对齐 Android OTG 页面并接入真实 ImageCaptureCore 传输。 final class WiredCameraTransferViewController: BaseViewController { private let viewModel: WiredCameraTransferViewModel @@ -27,9 +44,22 @@ final class WiredCameraTransferViewController: BaseViewController { private let formatButton = UIButton(type: .system) private let modeButton = UIButton(type: .system) private let statsCard = UIStackView() + private let photoPanelView = UIView() + private let selectAllBar = UIControl() + private let selectAllIconView = UIImageView() + private let selectAllTitleLabel = UILabel() + private let selectAllCountLabel = UILabel() + private let photoContentView = UIView() + private let sidebarView = WiredTransferTimeSidebarView() + private let sidebarDivider = UIView() + private let listContainerView = UIView() + private let expandSidebarButton = UIButton(type: .system) private let emptyLabel = UILabel() private var collectionView: UICollectionView! - private var dataSource: UICollectionViewDiffableDataSource! + private var dataSource: UICollectionViewDiffableDataSource! + private var currentSections: [WiredTransferListSection] = [] + private var pendingScrollSlotId: String? + private var selectAllBarHeightConstraint: Constraint? private let bottomBar = UIView() private let batchButton = UIButton(type: .system) private let historyImportButton = UIButton(type: .system) @@ -117,11 +147,34 @@ final class WiredCameraTransferViewController: BaseViewController { statsCard.axis = .horizontal statsCard.distribution = .fillEqually + photoPanelView.backgroundColor = .white + selectAllBar.backgroundColor = .white + selectAllIconView.contentMode = .scaleAspectFit + selectAllTitleLabel.text = "全选" + selectAllTitleLabel.font = .systemFont(ofSize: 13) + selectAllTitleLabel.textColor = AppColor.textPrimary + selectAllCountLabel.font = .systemFont(ofSize: 12) + selectAllCountLabel.textColor = AppColor.textSecondary + selectAllCountLabel.textAlignment = .right + sidebarDivider.backgroundColor = AppColor.border + listContainerView.backgroundColor = .white + expandSidebarButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) + expandSidebarButton.tintColor = AppColor.textSecondary + expandSidebarButton.backgroundColor = UIColor(hex: 0xF5F6F8) + expandSidebarButton.layer.cornerRadius = 8 + expandSidebarButton.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] + collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) - collectionView.backgroundColor = AppColor.pageBackground + collectionView.backgroundColor = .white collectionView.delegate = self + collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) - dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + collectionView.register( + WiredTransferSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier + ) + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier, @@ -133,8 +186,18 @@ final class WiredCameraTransferViewController: BaseViewController { cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) } return cell } + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + guard kind == UICollectionView.elementKindSectionHeader else { return nil } + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier, + for: indexPath + ) as! WiredTransferSectionHeaderView + header.apply(title: self?.currentSections[safe: indexPath.section]?.title ?? "") + return header + } - emptyLabel.text = "暂无照片" + emptyLabel.text = "暂无传输文件" emptyLabel.font = .systemFont(ofSize: 14) emptyLabel.textColor = AppColor.textTertiary emptyLabel.textAlignment = .center @@ -157,7 +220,17 @@ final class WiredCameraTransferViewController: BaseViewController { statusCard.addSubview(helpLabel) statusCard.addSubview(chipsStack) statusCard.addSubview(statsCard) - view.addSubview(collectionView) + view.addSubview(photoPanelView) + photoPanelView.addSubview(selectAllBar) + selectAllBar.addSubview(selectAllIconView) + selectAllBar.addSubview(selectAllTitleLabel) + selectAllBar.addSubview(selectAllCountLabel) + photoPanelView.addSubview(photoContentView) + photoContentView.addSubview(sidebarView) + photoContentView.addSubview(sidebarDivider) + photoContentView.addSubview(listContainerView) + listContainerView.addSubview(collectionView) + listContainerView.addSubview(expandSidebarButton) view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(batchButton) @@ -246,16 +319,47 @@ final class WiredCameraTransferViewController: BaseViewController { make.leading.equalTo(historyImportButton.snp.trailing).offset(8) make.trailing.equalToSuperview().offset(-16) } - collectionView.snp.makeConstraints { make in + photoPanelView.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom).offset(14) - make.leading.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(bottomBar.snp.top).offset(-10) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + selectAllBar.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + selectAllBarHeightConstraint = make.height.equalTo(WiredTransferMetrics.selectAllBarHeight).constraint + } + selectAllIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(10) + make.centerY.equalToSuperview() + make.size.equalTo(20) + } + selectAllTitleLabel.snp.makeConstraints { make in + make.leading.equalTo(selectAllIconView.snp.trailing).offset(8) + make.centerY.equalToSuperview() + } + selectAllCountLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(selectAllTitleLabel.snp.trailing).offset(8) + make.trailing.equalToSuperview().offset(-10) + make.centerY.equalToSuperview() + } + photoContentView.snp.makeConstraints { make in + make.top.equalTo(selectAllBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + expandSidebarButton.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.width.equalTo(24) + make.height.equalTo(48) } emptyLabel.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } + updateSidebarLayout() } override func bindActions() { @@ -273,6 +377,13 @@ final class WiredCameraTransferViewController: BaseViewController { batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) specifyButton.addTarget(self, action: #selector(specifyTapped), for: .touchUpInside) + selectAllBar.addTarget(self, action: #selector(selectAllTapped), for: .touchUpInside) + expandSidebarButton.addTarget(self, action: #selector(toggleSidebarTapped), for: .touchUpInside) + sidebarView.onToggleSidebar = { [weak self] in self?.viewModel.toggleSidebar() } + sidebarView.onTimeSlotSelected = { [weak self] slotId in + self?.pendingScrollSlotId = slotId + self?.viewModel.selectTimeSlot(id: slotId) + } formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) } }) @@ -300,13 +411,25 @@ final class WiredCameraTransferViewController: BaseViewController { helpLabel.attributedText = helpText(viewModel.sonyMTPHint) rebuildStats() - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([0]) - snapshot.appendItems(viewModel.filteredPhotos()) - dataSource.apply(snapshot, animatingDifferences: true) + let sections = viewModel.photoSections + currentSections = sections.map(WiredTransferListSection.init) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(currentSections) + for (index, section) in sections.enumerated() { + snapshot.appendItems(section.photos, toSection: currentSections[index]) + } + dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in + self?.scrollToPendingTimeSlotIfNeeded() + } - emptyLabel.isHidden = !viewModel.filteredPhotos().isEmpty - collectionView.isHidden = viewModel.filteredPhotos().isEmpty + sidebarView.apply( + groups: viewModel.sidebarGroups, + selectedSlotId: viewModel.selectedTimeSlotId + ) + updateSelectAllBar() + updateSidebarLayout() + emptyLabel.isHidden = !sections.isEmpty + photoPanelView.isHidden = sections.isEmpty if viewModel.selectUploadMode { let count = viewModel.selectedPhotoIds.count batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal) @@ -316,6 +439,53 @@ final class WiredCameraTransferViewController: BaseViewController { historyImportButton.setTitle("历史导入", for: .normal) } + private func updateSelectAllBar() { + let showSelectAll = viewModel.selectUploadMode && !viewModel.photoSections.isEmpty + selectAllBar.isHidden = !showSelectAll + selectAllBarHeightConstraint?.update(offset: showSelectAll ? WiredTransferMetrics.selectAllBarHeight : 0) + let allSelected = viewModel.allVisibleSelectablePhotosSelected + selectAllIconView.image = UIImage(systemName: allSelected ? "checkmark.circle.fill" : "circle") + selectAllIconView.tintColor = allSelected ? AppColor.primary : AppColor.textTertiary + selectAllCountLabel.text = "已选 \(viewModel.selectedVisibleUploadCount) / \(viewModel.selectableVisiblePhotoIds.count)" + selectAllBar.isEnabled = !viewModel.selectableVisiblePhotoIds.isEmpty + selectAllBar.alpha = selectAllBar.isEnabled ? 1 : 0.55 + } + + private func updateSidebarLayout() { + let showSidebar = viewModel.sidebarExpanded && !viewModel.sidebarGroups.isEmpty + sidebarView.isHidden = !showSidebar + sidebarDivider.isHidden = !showSidebar + expandSidebarButton.isHidden = viewModel.sidebarExpanded || viewModel.sidebarGroups.isEmpty + sidebarView.snp.remakeConstraints { make in + make.top.leading.bottom.equalToSuperview() + make.width.equalTo(showSidebar ? WiredTransferMetrics.sidebarWidth : 0) + } + sidebarDivider.snp.remakeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.equalTo(sidebarView.snp.trailing) + make.width.equalTo(showSidebar ? 1 : 0) + } + listContainerView.snp.remakeConstraints { make in + make.top.bottom.trailing.equalToSuperview() + make.leading.equalTo(sidebarDivider.snp.trailing) + } + } + + private func scrollToPendingTimeSlotIfNeeded() { + guard let slotId = pendingScrollSlotId, + let sectionIndex = currentSections.firstIndex(where: { $0.id == slotId }), + collectionView.numberOfItems(inSection: sectionIndex) > 0 else { + pendingScrollSlotId = nil + return + } + collectionView.scrollToItem( + at: IndexPath(item: 0, section: sectionIndex), + at: .top, + animated: true + ) + pendingScrollSlotId = nil + } + private func rebuildStats() { statsCard.arrangedSubviews.forEach { view in statsCard.removeArrangedSubview(view) @@ -373,16 +543,29 @@ final class WiredCameraTransferViewController: BaseViewController { } private func makeLayout() -> UICollectionViewCompositionalLayout { - UICollectionViewCompositionalLayout { _, environment in - let spacing: CGFloat = 8 - let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3 - let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 58)) + UICollectionViewCompositionalLayout { _, _ in + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(WiredTransferMetrics.rowHeight) + ) let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 58)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3) - group.interItemSpacing = .fixed(spacing) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(WiredTransferMetrics.rowHeight) + ) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) - section.interGroupSpacing = 12 + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(WiredTransferMetrics.sectionHeaderHeight) + ) + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + header.pinToVisibleBounds = true + section.boundarySupplementaryItems = [header] return section } } @@ -445,6 +628,14 @@ final class WiredCameraTransferViewController: BaseViewController { viewModel.onSpecifyUploadButtonClick() } + @objc private func selectAllTapped() { + viewModel.toggleVisibleSelectablePhotos() + } + + @objc private func toggleSidebarTapped() { + viewModel.toggleSidebar() + } + @objc private func tabTapped(_ sender: UIButton) { guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return } viewModel.selectTab(tab) @@ -467,19 +658,47 @@ extension WiredCameraTransferViewController: UICollectionViewDelegate { } } -/// 有线传输照片宫格 Cell。 +/// 有线传输照片列表时间段 Header。 +private final class WiredTransferSectionHeaderView: UICollectionReusableView { + static let reuseIdentifier = "WiredTransferSectionHeaderView" + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.pageBackground + titleLabel.font = .systemFont(ofSize: 11) + titleLabel.textColor = AppColor.textSecondary + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(10) + make.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(title: String) { + titleLabel.text = title + } +} + +/// 有线传输照片列表 Cell。 private final class WiredTransferPhotoCell: UICollectionViewCell { static let reuseIdentifier = "WiredTransferPhotoCell" - private static let previewImageSize = CGSize(width: 180, height: 180) + private static let previewImageSize = CGSize(width: 96, height: 96) + private let selectionIconView = UIImageView() private let imageView = UIImageView() private let statusLabel = UILabel() private let titleLabel = UILabel() private let sizeLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .default) - private let retryButton = UIButton(type: .system) - private let deleteButton = UIButton(type: .system) - private let selectionOverlay = UIImageView() + private let menuButton = UIButton(type: .system) + private let separatorView = UIView() var onRetry: (() -> Void)? var onDelete: (() -> Void)? @@ -500,9 +719,12 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { imageView.image = nil onRetry = nil onDelete = nil + menuButton.menu = nil } func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { + let canSelect = item.canSelectForUpload + let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1 if let url = item.thumbnailURL { let processor = DownsamplingImageProcessor(size: Self.previewImageSize) imageView.kf.setImage( @@ -518,114 +740,301 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { imageView.image = UIImage(systemName: "photo") imageView.tintColor = AppColor.textTertiary } + imageView.alpha = rowAlpha titleLabel.text = item.fileName + titleLabel.alpha = rowAlpha sizeLabel.text = item.fileSizeText - statusLabel.text = statusText(item.status, progress: item.progress) - statusLabel.backgroundColor = statusColor(item.status).withAlphaComponent(0.90) + sizeLabel.alpha = rowAlpha + statusLabel.text = statusText(item.status) + statusLabel.textColor = statusTextColor(item.status) + statusLabel.backgroundColor = statusBackgroundColor(item.status) progressView.isHidden = item.status != .uploading && item.status != .transferring progressView.progress = Float(item.progress) / 100.0 - retryButton.isHidden = item.status != .failed - deleteButton.isHidden = selectionMode - selectionOverlay.isHidden = !selectionMode - selectionOverlay.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") - selectionOverlay.tintColor = selected ? AppColor.primary : .white + selectionIconView.isHidden = !selectionMode + selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") + selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) + menuButton.isHidden = selectionMode + menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending) + updateImageConstraints(selectionMode: selectionMode) } private func setupUI() { contentView.backgroundColor = .white - contentView.layer.cornerRadius = 8 - contentView.clipsToBounds = true + selectionIconView.contentMode = .scaleAspectFit imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true - statusLabel.font = .systemFont(ofSize: 10, weight: .medium) - statusLabel.textColor = .white + imageView.layer.cornerRadius = 6 + statusLabel.font = .systemFont(ofSize: 9) statusLabel.textAlignment = .center - statusLabel.layer.cornerRadius = 4 + statusLabel.layer.cornerRadius = 3 statusLabel.clipsToBounds = true - titleLabel.font = .systemFont(ofSize: 11, weight: .medium) + titleLabel.font = .systemFont(ofSize: 12, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.lineBreakMode = .byTruncatingMiddle sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.textColor = AppColor.textTertiary - retryButton.setImage(UIImage(systemName: "arrow.clockwise"), for: .normal) - retryButton.tintColor = AppColor.danger - deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) - deleteButton.tintColor = AppColor.textTertiary - selectionOverlay.contentMode = .scaleAspectFit + progressView.progressTintColor = AppColor.primary + progressView.trackTintColor = AppColor.border + menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal) + menuButton.tintColor = AppColor.textSecondary + menuButton.showsMenuAsPrimaryAction = true + separatorView.backgroundColor = AppColor.border + contentView.addSubview(selectionIconView) contentView.addSubview(imageView) - imageView.addSubview(statusLabel) - imageView.addSubview(selectionOverlay) contentView.addSubview(titleLabel) + contentView.addSubview(statusLabel) contentView.addSubview(sizeLabel) contentView.addSubview(progressView) - contentView.addSubview(retryButton) - contentView.addSubview(deleteButton) + contentView.addSubview(menuButton) + contentView.addSubview(separatorView) - imageView.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - make.height.equalTo(contentView.snp.width) + selectionIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(10) + make.centerY.equalToSuperview() + make.size.equalTo(20) + } + updateImageConstraints(selectionMode: true) + titleLabel.snp.makeConstraints { make in + make.top.equalTo(imageView).offset(1) + make.leading.equalTo(imageView.snp.trailing).offset(8) + make.trailing.equalTo(statusLabel.snp.leading).offset(-4) } statusLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview().offset(5) - make.height.equalTo(18) + make.centerY.equalTo(titleLabel) + make.trailing.equalTo(menuButton.snp.leading).offset(-4) + make.height.equalTo(17) make.width.greaterThanOrEqualTo(44) } - selectionOverlay.snp.makeConstraints { make in - make.top.trailing.equalToSuperview().inset(6) - make.size.equalTo(22) - } - titleLabel.snp.makeConstraints { make in - make.top.equalTo(imageView.snp.bottom).offset(5) - make.leading.equalToSuperview().offset(6) - make.trailing.equalTo(deleteButton.snp.leading).offset(-4) - } sizeLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(2) - make.leading.trailing.equalTo(titleLabel) + make.top.equalTo(titleLabel.snp.bottom).offset(5) + make.leading.equalTo(titleLabel) + make.trailing.equalToSuperview().offset(-10) } progressView.snp.makeConstraints { make in - make.leading.trailing.bottom.equalToSuperview().inset(6) + make.leading.equalTo(titleLabel) + make.trailing.equalToSuperview().offset(-10) + make.top.equalTo(sizeLabel.snp.bottom).offset(7) + make.height.equalTo(2) } - retryButton.snp.makeConstraints { make in + menuButton.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) - make.trailing.equalToSuperview().offset(-2) - make.size.equalTo(26) + make.trailing.equalToSuperview().offset(-4) + make.size.equalTo(32) } - deleteButton.snp.makeConstraints { make in - make.centerY.equalTo(titleLabel) - make.trailing.equalToSuperview().offset(-2) - make.size.equalTo(26) + separatorView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(10) + make.bottom.equalToSuperview() + make.height.equalTo(1 / UIScreen.main.scale) } - - retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) - deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } - private func statusText(_ status: TravelAlbumOTGUploadStatus, progress: Int) -> String { + private func makeMenu(canRetry: Bool) -> UIMenu { + let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in + self?.onRetry?() + } + retry.attributes = canRetry ? [] : [.disabled] + let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in + self?.onDelete?() + } + return UIMenu(children: [retry, delete]) + } + + private func updateImageConstraints(selectionMode: Bool) { + imageView.snp.remakeConstraints { make in + if selectionMode { + make.leading.equalTo(selectionIconView.snp.trailing).offset(8) + } else { + make.leading.equalToSuperview().offset(10) + } + make.top.equalToSuperview().offset(8) + make.size.equalTo(48) + make.bottom.lessThanOrEqualToSuperview().offset(-8) + } + } + + private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String { switch status { case .pending: return "待上传" - case .transferring: return "传输\(progress)%" - case .uploading: return "上传\(progress)%" + case .transferring: return "传输中" + case .uploading: return "上传中" case .uploaded: return "已上传" - case .failed: return "失败" + case .failed: return "上传失败" } } - private func statusColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { + private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { switch status { - case .pending: return AppColor.textTertiary + case .pending: return AppColor.textSecondary case .transferring, .uploading: return AppColor.primary case .uploaded: return UIColor(hex: 0x16A34A) case .failed: return AppColor.danger } } - @objc private func retryTapped() { - onRetry?() - } - - @objc private func deleteTapped() { - onDelete?() + private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { + switch status { + case .pending: + return AppColor.pageBackground + case .transferring, .uploading: + return AppColor.primary.withAlphaComponent(0.12) + case .uploaded: + return UIColor(hex: 0x16A34A).withAlphaComponent(0.12) + case .failed: + return AppColor.danger.withAlphaComponent(0.12) + } + } +} + +/// 有线传输页左侧时间快速导航。 +private final class WiredTransferTimeSidebarView: UIView { + var onToggleSidebar: (() -> Void)? + var onTimeSlotSelected: ((String) -> Void)? + + private let headerButton = UIButton(type: .system) + private let scrollView = UIScrollView() + private let stackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) { + stackView.arrangedSubviews.forEach { view in + stackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + for group in groups { + let dateContainer = UIView() + let dateLabel = UILabel() + dateLabel.text = group.dateLabel + dateLabel.font = .systemFont(ofSize: 12) + dateLabel.textColor = AppColor.textTertiary + dateContainer.addSubview(dateLabel) + stackView.addArrangedSubview(dateContainer) + dateContainer.snp.makeConstraints { make in + make.height.equalTo(24) + } + dateLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(10) + make.trailing.equalToSuperview() + make.bottom.equalToSuperview().offset(-4) + } + + for slot in group.slots { + let row = TimeSlotRowView() + row.apply(slot: slot, selected: slot.id == selectedSlotId) + row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) } + stackView.addArrangedSubview(row) + row.snp.makeConstraints { make in + make.height.equalTo(52) + } + } + } + } + + private func setupUI() { + backgroundColor = UIColor(hex: 0xF5F6F8) + headerButton.setTitle("收起", for: .normal) + headerButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) + headerButton.tintColor = AppColor.textSecondary + headerButton.setTitleColor(AppColor.textSecondary, for: .normal) + headerButton.titleLabel?.font = .systemFont(ofSize: 11) + headerButton.semanticContentAttribute = .forceLeftToRight + headerButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) + scrollView.showsVerticalScrollIndicator = false + stackView.axis = .vertical + + addSubview(headerButton) + addSubview(scrollView) + scrollView.addSubview(stackView) + + headerButton.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(36) + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(headerButton.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalToSuperview() + } + } + + @objc private func toggleTapped() { + onToggleSidebar?() + } +} + +private final class TimeSlotRowView: UIControl { + private let timeLabel = UILabel() + private let countLabel = UILabel() + private let arrowView = UIImageView(image: UIImage(systemName: "play.fill")) + + var onTap: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(slot: TravelAlbumOTGTimeSlot, selected: Bool) { + backgroundColor = selected ? .white : .clear + timeLabel.text = slot.slotStartLabel + timeLabel.font = .systemFont(ofSize: 13, weight: selected ? .semibold : .regular) + timeLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary + arrowView.tintColor = selected ? AppColor.primary : AppColor.textPrimary + countLabel.text = "\(slot.photoCount)张" + } + + private func setupUI() { + arrowView.contentMode = .scaleAspectFit + timeLabel.textAlignment = .center + countLabel.font = .systemFont(ofSize: 10) + countLabel.textColor = AppColor.textTertiary + countLabel.textAlignment = .center + addTarget(self, action: #selector(rowTapped), for: .touchUpInside) + + addSubview(arrowView) + addSubview(timeLabel) + addSubview(countLabel) + + arrowView.snp.makeConstraints { make in + make.centerY.equalTo(timeLabel) + make.trailing.equalTo(timeLabel.snp.leading).offset(-2) + make.size.equalTo(10) + } + timeLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(8) + make.centerX.equalToSuperview().offset(6) + } + countLabel.snp.makeConstraints { make in + make.top.equalTo(timeLabel.snp.bottom).offset(2) + make.centerX.equalToSuperview() + } + } + + @objc private func rowTapped() { + onTap?() + } +} + +private extension Array { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index 1700ceb..7648a87 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -74,7 +74,7 @@ final class TravelAlbumAPITests: XCTestCase { } func testUploadMaterialBuildsPathAndBody() async throws { - let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"file_name":"A.JPG","file_url":"https://cdn/a.jpg"}"#) + let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#) let session = MockURLSession(responses: [material]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 946db25..ac67bc7 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -142,6 +142,68 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertTrue(viewModel.filteredPhotos().isEmpty) } + func testTransferPhotoSectionsAndSidebarUseDescendingHalfHourSlots() { + let photos = [ + makeOTGPhotoItem(id: "a", capturedAt: "2026-07-08 12:05:00"), + makeOTGPhotoItem(id: "b", capturedAt: "2026-07-08 12:29:59"), + makeOTGPhotoItem(id: "c", capturedAt: "2026-07-08 12:30:00"), + makeOTGPhotoItem(id: "d", capturedAt: "2026-07-07 18:10:00"), + ] + + let sections = photos.buildPhotoSections() + let groups = photos.buildSidebarGroups() + + XCTAssertEqual(sections.map(\.slotId), [ + "2026-07-08T12:30", + "2026-07-08T12:00", + "2026-07-07T18:00", + ]) + XCTAssertEqual(sections[0].headerTitle, "07月08日 12:30 - 13:00") + XCTAssertEqual(sections[1].photos.map(\.id), ["b", "a"]) + XCTAssertEqual(groups.map(\.dateLabel), ["07月08日", "07月07日"]) + XCTAssertEqual(groups.first?.slots.map(\.slotStartLabel), ["12:30", "12:00"]) + XCTAssertEqual(groups.first?.slots.last?.photoCount, 2) + } + + func testSelectedTimeSlotFallsBackWhenTabChanges() throws { + let context = makeOTGTestContext() + context.store.save([ + try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending), + try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:30:00", status: .failed), + ], albumId: 9) + let viewModel = makeWiredViewModel( + context: context, + manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])) + ) + + viewModel.reloadLocalPhotos() + viewModel.selectTimeSlot(id: "2026-07-08T12:00") + viewModel.selectTab(.failed) + + XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30") + } + + func testSelectionModeAllowsPendingAndFailedOnly() throws { + let context = makeOTGTestContext() + context.store.save([ + try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending), + try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:10:00", status: .failed), + try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:15:00", status: .uploaded), + ], albumId: 9) + let viewModel = makeWiredViewModel( + context: context, + manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])) + ) + + viewModel.reloadLocalPhotos() + viewModel.onBatchUploadButtonClick() + viewModel.toggleTransferPhotoSelection(photoId: "pending") + viewModel.toggleTransferPhotoSelection(photoId: "failed") + viewModel.toggleTransferPhotoSelection(photoId: "uploaded") + + XCTAssertEqual(viewModel.selectedPhotoIds, ["pending", "failed"]) + } + func testEnteringPageDoesNotListCameraObjects() async { let context = makeOTGTestContext() let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()) @@ -422,6 +484,66 @@ private func makeWiredViewModel( ) } +private func makeOTGPhotoItem( + id: String, + capturedAt: String, + status: TravelAlbumOTGUploadStatus = .pending +) -> TravelAlbumOTGPhotoItem { + TravelAlbumOTGPhotoItem( + id: id, + sourceId: id, + fileName: "\(id).JPG", + thumbnailURL: nil, + capturedAt: capturedAt, + fileSizeText: "1 KB", + fileSizeBytes: 1024, + status: status, + progress: 0, + errorMessage: nil, + localPath: "originals/\(id).JPG", + remoteUrl: "" + ) +} + +private func makeOTGRecord( + id: String, + capturedAt: String, + status: TravelAlbumOTGUploadStatus +) -> TravelAlbumOTGPhotoRecord { + TravelAlbumOTGPhotoRecord( + id: id, + fileName: "\(id).JPG", + localPath: "originals/\(id).JPG", + capturedAt: capturedAt, + fileSizeBytes: 1024, + status: status, + albumId: 9, + userId: "u1" + ) +} + +private func makePersistedOTGRecord( + context: OTGTestContext, + id: String, + capturedAt: String, + status: TravelAlbumOTGUploadStatus +) throws -> TravelAlbumOTGPhotoRecord { + let relativePath = "originals/\(id).JPG" + let directory = try context.store.originalsDirectory(albumId: 9) + let url = directory.appendingPathComponent("\(id).JPG") + try Data([0xFF, 0xD8, 0xFF]).write(to: url) + return TravelAlbumOTGPhotoRecord( + id: id, + fileName: "\(id).JPG", + localPath: relativePath, + capturedAt: capturedAt, + fileSizeBytes: 3, + status: status, + albumId: 9, + userId: "u1" + ) +} + @MainActor private func makeOTGTestContext() -> OTGTestContext { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)