Sync OTG transfer photo list UI
This commit is contained in:
@ -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 {
|
||||||
/// 相机照片类型。
|
/// 相机照片类型。
|
||||||
|
|||||||
@ -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("正在上传,请稍候")
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user