Sync OTG transfer photo list UI

This commit is contained in:
2026-07-08 11:00:44 +08:00
parent d642234b38
commit 87fba3d5d6
5 changed files with 839 additions and 91 deletions

View File

@ -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 /// OTG Tab
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all 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` /// Android `OtgPhotoFormatMatcher`
enum TravelAlbumOTGPhotoFormatMatcher { enum TravelAlbumOTGPhotoFormatMatcher {
/// ///

View File

@ -17,10 +17,16 @@ final class WiredCameraTransferViewModel {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
} }
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] { private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
didSet { notifyStateChanged() } didSet {
normalizeSelectedTimeSlot()
notifyStateChanged()
}
} }
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all { private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
didSet { notifyStateChanged() } didSet {
normalizeSelectedTimeSlot()
notifyStateChanged()
}
} }
private(set) var selectedPhotoIds: Set<String> = [] { private(set) var selectedPhotoIds: Set<String> = [] {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
@ -28,6 +34,12 @@ final class WiredCameraTransferViewModel {
private(set) var selectUploadMode = false { private(set) var selectUploadMode = false {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
} }
private(set) var sidebarExpanded = true {
didSet { notifyStateChanged() }
}
private(set) var selectedTimeSlotId: String? {
didSet { notifyStateChanged() }
}
private(set) var sonyMTPHint: String? private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false 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<String> {
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() { func start() {
connectionManager.configureLiveTransfer(albumID: albumId) connectionManager.configureLiveTransfer(albumID: albumId)
@ -178,6 +216,17 @@ final class WiredCameraTransferViewModel {
selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id))) 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) { func selectTransferMode(_ option: String) {
guard option == "边拍边传" || option == "拍后传输" else { return } 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() { func uploadSelectedTransferPhotos() {
let ids = selectedPhotoIds let ids = selectedPhotoIds
@ -303,6 +364,17 @@ final class WiredCameraTransferViewModel {
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt } 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<String>, emptyMessage: String) { private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
guard !isUploading else { guard !isUploading else {
showMessage("正在上传,请稍候") showMessage("正在上传,请稍候")

View File

@ -7,6 +7,23 @@ import Kingfisher
import SnapKit import SnapKit
import UIKit 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 /// 线 Android OTG ImageCaptureCore
final class WiredCameraTransferViewController: BaseViewController { final class WiredCameraTransferViewController: BaseViewController {
private let viewModel: WiredCameraTransferViewModel private let viewModel: WiredCameraTransferViewModel
@ -27,9 +44,22 @@ final class WiredCameraTransferViewController: BaseViewController {
private let formatButton = UIButton(type: .system) private let formatButton = UIButton(type: .system)
private let modeButton = UIButton(type: .system) private let modeButton = UIButton(type: .system)
private let statsCard = UIStackView() 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 let emptyLabel = UILabel()
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumOTGPhotoItem>! private var dataSource: UICollectionViewDiffableDataSource<WiredTransferListSection, TravelAlbumOTGPhotoItem>!
private var currentSections: [WiredTransferListSection] = []
private var pendingScrollSlotId: String?
private var selectAllBarHeightConstraint: Constraint?
private let bottomBar = UIView() private let bottomBar = UIView()
private let batchButton = UIButton(type: .system) private let batchButton = UIButton(type: .system)
private let historyImportButton = UIButton(type: .system) private let historyImportButton = UIButton(type: .system)
@ -117,11 +147,34 @@ final class WiredCameraTransferViewController: BaseViewController {
statsCard.axis = .horizontal statsCard.axis = .horizontal
statsCard.distribution = .fillEqually 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 = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = AppColor.pageBackground collectionView.backgroundColor = .white
collectionView.delegate = self collectionView.delegate = self
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)
collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumOTGPhotoItem>(collectionView: collectionView) { collectionView.register(
WiredTransferSectionHeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier
)
dataSource = UICollectionViewDiffableDataSource<WiredTransferListSection, TravelAlbumOTGPhotoItem>(collectionView: collectionView) {
[weak self] collectionView, indexPath, item in [weak self] collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell( let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier, withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier,
@ -133,8 +186,18 @@ final class WiredCameraTransferViewController: BaseViewController {
cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) } cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) }
return cell 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.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center emptyLabel.textAlignment = .center
@ -157,7 +220,17 @@ final class WiredCameraTransferViewController: BaseViewController {
statusCard.addSubview(helpLabel) statusCard.addSubview(helpLabel)
statusCard.addSubview(chipsStack) statusCard.addSubview(chipsStack)
statusCard.addSubview(statsCard) 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(emptyLabel)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(batchButton) bottomBar.addSubview(batchButton)
@ -246,16 +319,47 @@ final class WiredCameraTransferViewController: BaseViewController {
make.leading.equalTo(historyImportButton.snp.trailing).offset(8) make.leading.equalTo(historyImportButton.snp.trailing).offset(8)
make.trailing.equalToSuperview().offset(-16) make.trailing.equalToSuperview().offset(-16)
} }
collectionView.snp.makeConstraints { make in photoPanelView.snp.makeConstraints { make in
make.top.equalTo(statusCard.snp.bottom).offset(14) make.top.equalTo(statusCard.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top).offset(-10) 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 emptyLabel.snp.makeConstraints { make in
make.top.equalTo(statusCard.snp.bottom) make.top.equalTo(statusCard.snp.bottom)
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top) make.bottom.equalTo(bottomBar.snp.top)
} }
updateSidebarLayout()
} }
override func bindActions() { override func bindActions() {
@ -273,6 +377,13 @@ final class WiredCameraTransferViewController: BaseViewController {
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
specifyButton.addTarget(self, action: #selector(specifyTapped), 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 formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in
UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) } UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) }
}) })
@ -300,13 +411,25 @@ final class WiredCameraTransferViewController: BaseViewController {
helpLabel.attributedText = helpText(viewModel.sonyMTPHint) helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
rebuildStats() rebuildStats()
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumOTGPhotoItem>() let sections = viewModel.photoSections
snapshot.appendSections([0]) currentSections = sections.map(WiredTransferListSection.init)
snapshot.appendItems(viewModel.filteredPhotos()) var snapshot = NSDiffableDataSourceSnapshot<WiredTransferListSection, TravelAlbumOTGPhotoItem>()
dataSource.apply(snapshot, animatingDifferences: true) 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 sidebarView.apply(
collectionView.isHidden = viewModel.filteredPhotos().isEmpty groups: viewModel.sidebarGroups,
selectedSlotId: viewModel.selectedTimeSlotId
)
updateSelectAllBar()
updateSidebarLayout()
emptyLabel.isHidden = !sections.isEmpty
photoPanelView.isHidden = sections.isEmpty
if viewModel.selectUploadMode { if viewModel.selectUploadMode {
let count = viewModel.selectedPhotoIds.count let count = viewModel.selectedPhotoIds.count
batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal) batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal)
@ -316,6 +439,53 @@ final class WiredCameraTransferViewController: BaseViewController {
historyImportButton.setTitle("历史导入", for: .normal) 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() { private func rebuildStats() {
statsCard.arrangedSubviews.forEach { view in statsCard.arrangedSubviews.forEach { view in
statsCard.removeArrangedSubview(view) statsCard.removeArrangedSubview(view)
@ -373,16 +543,29 @@ final class WiredCameraTransferViewController: BaseViewController {
} }
private func makeLayout() -> UICollectionViewCompositionalLayout { private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in UICollectionViewCompositionalLayout { _, _ in
let spacing: CGFloat = 8 let itemSize = NSCollectionLayoutSize(
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3 widthDimension: .fractionalWidth(1),
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 58)) heightDimension: .estimated(WiredTransferMetrics.rowHeight)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize) let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 58)) let groupSize = NSCollectionLayoutSize(
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3) widthDimension: .fractionalWidth(1),
group.interItemSpacing = .fixed(spacing) heightDimension: .estimated(WiredTransferMetrics.rowHeight)
)
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group) 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 return section
} }
} }
@ -445,6 +628,14 @@ final class WiredCameraTransferViewController: BaseViewController {
viewModel.onSpecifyUploadButtonClick() viewModel.onSpecifyUploadButtonClick()
} }
@objc private func selectAllTapped() {
viewModel.toggleVisibleSelectablePhotos()
}
@objc private func toggleSidebarTapped() {
viewModel.toggleSidebar()
}
@objc private func tabTapped(_ sender: UIButton) { @objc private func tabTapped(_ sender: UIButton) {
guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return } guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return }
viewModel.selectTab(tab) 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 { private final class WiredTransferPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "WiredTransferPhotoCell" 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 imageView = UIImageView()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default) private let progressView = UIProgressView(progressViewStyle: .default)
private let retryButton = UIButton(type: .system) private let menuButton = UIButton(type: .system)
private let deleteButton = UIButton(type: .system) private let separatorView = UIView()
private let selectionOverlay = UIImageView()
var onRetry: (() -> Void)? var onRetry: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
@ -500,9 +719,12 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.image = nil imageView.image = nil
onRetry = nil onRetry = nil
onDelete = nil onDelete = nil
menuButton.menu = nil
} }
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { 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 { if let url = item.thumbnailURL {
let processor = DownsamplingImageProcessor(size: Self.previewImageSize) let processor = DownsamplingImageProcessor(size: Self.previewImageSize)
imageView.kf.setImage( imageView.kf.setImage(
@ -518,114 +740,301 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.image = UIImage(systemName: "photo") imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.textTertiary imageView.tintColor = AppColor.textTertiary
} }
imageView.alpha = rowAlpha
titleLabel.text = item.fileName titleLabel.text = item.fileName
titleLabel.alpha = rowAlpha
sizeLabel.text = item.fileSizeText sizeLabel.text = item.fileSizeText
statusLabel.text = statusText(item.status, progress: item.progress) sizeLabel.alpha = rowAlpha
statusLabel.backgroundColor = statusColor(item.status).withAlphaComponent(0.90) statusLabel.text = statusText(item.status)
statusLabel.textColor = statusTextColor(item.status)
statusLabel.backgroundColor = statusBackgroundColor(item.status)
progressView.isHidden = item.status != .uploading && item.status != .transferring progressView.isHidden = item.status != .uploading && item.status != .transferring
progressView.progress = Float(item.progress) / 100.0 progressView.progress = Float(item.progress) / 100.0
retryButton.isHidden = item.status != .failed selectionIconView.isHidden = !selectionMode
deleteButton.isHidden = selectionMode selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionOverlay.isHidden = !selectionMode selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
selectionOverlay.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") menuButton.isHidden = selectionMode
selectionOverlay.tintColor = selected ? AppColor.primary : .white menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending)
updateImageConstraints(selectionMode: selectionMode)
} }
private func setupUI() { private func setupUI() {
contentView.backgroundColor = .white contentView.backgroundColor = .white
contentView.layer.cornerRadius = 8 selectionIconView.contentMode = .scaleAspectFit
contentView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
statusLabel.font = .systemFont(ofSize: 10, weight: .medium) imageView.layer.cornerRadius = 6
statusLabel.textColor = .white statusLabel.font = .systemFont(ofSize: 9)
statusLabel.textAlignment = .center statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4 statusLabel.layer.cornerRadius = 3
statusLabel.clipsToBounds = true statusLabel.clipsToBounds = true
titleLabel.font = .systemFont(ofSize: 11, weight: .medium) titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
titleLabel.textColor = AppColor.textPrimary titleLabel.textColor = AppColor.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle titleLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary sizeLabel.textColor = AppColor.textTertiary
retryButton.setImage(UIImage(systemName: "arrow.clockwise"), for: .normal) progressView.progressTintColor = AppColor.primary
retryButton.tintColor = AppColor.danger progressView.trackTintColor = AppColor.border
deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal)
deleteButton.tintColor = AppColor.textTertiary menuButton.tintColor = AppColor.textSecondary
selectionOverlay.contentMode = .scaleAspectFit menuButton.showsMenuAsPrimaryAction = true
separatorView.backgroundColor = AppColor.border
contentView.addSubview(selectionIconView)
contentView.addSubview(imageView) contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(selectionOverlay)
contentView.addSubview(titleLabel) contentView.addSubview(titleLabel)
contentView.addSubview(statusLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
contentView.addSubview(progressView) contentView.addSubview(progressView)
contentView.addSubview(retryButton) contentView.addSubview(menuButton)
contentView.addSubview(deleteButton) contentView.addSubview(separatorView)
imageView.snp.makeConstraints { make in selectionIconView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview() make.leading.equalToSuperview().offset(10)
make.height.equalTo(contentView.snp.width) 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 statusLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(5) make.centerY.equalTo(titleLabel)
make.height.equalTo(18) make.trailing.equalTo(menuButton.snp.leading).offset(-4)
make.height.equalTo(17)
make.width.greaterThanOrEqualTo(44) 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 sizeLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2) make.top.equalTo(titleLabel.snp.bottom).offset(5)
make.leading.trailing.equalTo(titleLabel) make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-10)
} }
progressView.snp.makeConstraints { make in 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.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-2) make.trailing.equalToSuperview().offset(-4)
make.size.equalTo(26) make.size.equalTo(32)
}
separatorView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(10)
make.bottom.equalToSuperview()
make.height.equalTo(1 / UIScreen.main.scale)
} }
deleteButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-2)
make.size.equalTo(26)
} }
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) private func makeMenu(canRetry: Bool) -> UIMenu {
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) 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 statusText(_ status: TravelAlbumOTGUploadStatus, progress: Int) -> String { 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 { switch status {
case .pending: return "待上传" case .pending: return "待上传"
case .transferring: return "传输\(progress)%" case .transferring: return "传输"
case .uploading: return "上传\(progress)%" case .uploading: return "上传"
case .uploaded: return "已上传" case .uploaded: return "已上传"
case .failed: return "失败" case .failed: return "上传失败"
} }
} }
private func statusColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
switch status { switch status {
case .pending: return AppColor.textTertiary case .pending: return AppColor.textSecondary
case .transferring, .uploading: return AppColor.primary case .transferring, .uploading: return AppColor.primary
case .uploaded: return UIColor(hex: 0x16A34A) case .uploaded: return UIColor(hex: 0x16A34A)
case .failed: return AppColor.danger case .failed: return AppColor.danger
} }
} }
@objc private func retryTapped() { private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
onRetry?() 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)
} }
}
@objc private func deleteTapped() { }
onDelete?()
/// 线
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
} }
} }

View File

@ -74,7 +74,7 @@ final class TravelAlbumAPITests: XCTestCase {
} }
func testUploadMaterialBuildsPathAndBody() async throws { 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 session = MockURLSession(responses: [material])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))

View File

@ -142,6 +142,68 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.filteredPhotos().isEmpty) 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 { func testEnteringPageDoesNotListCameraObjects() async {
let context = makeOTGTestContext() let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()) 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 @MainActor
private func makeOTGTestContext() -> OTGTestContext { private func makeOTGTestContext() -> OTGTestContext {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)