Update OTG transfer workflow
This commit is contained in:
@ -15,8 +15,8 @@ final class CanonRemoteCaptureService {
|
|||||||
|
|
||||||
private var albumID: Int?
|
private var albumID: Int?
|
||||||
private let photoRepository: PhotoRepositoryProtocol
|
private let photoRepository: PhotoRepositoryProtocol
|
||||||
/// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。
|
/// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。
|
||||||
var onShotSaved: ((String) -> Void)?
|
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
|
||||||
|
|
||||||
private var eventPollTimer: Timer?
|
private var eventPollTimer: Timer?
|
||||||
private var catalogPollTimer: Timer?
|
private var catalogPollTimer: Timer?
|
||||||
@ -175,14 +175,14 @@ final class CanonRemoteCaptureService {
|
|||||||
|
|
||||||
lastDownloadedSignature = objectID
|
lastDownloadedSignature = objectID
|
||||||
|
|
||||||
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
guard let record = await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||||
knownCatalogIDs.remove(objectID)
|
knownCatalogIDs.remove(objectID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedShotCount += 1
|
savedShotCount += 1
|
||||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||||
onShotSaved?(filename)
|
onShotSaved?(record)
|
||||||
} catch {
|
} catch {
|
||||||
knownCatalogIDs.remove(objectID)
|
knownCatalogIDs.remove(objectID)
|
||||||
OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||||
@ -417,23 +417,23 @@ final class CanonRemoteCaptureService {
|
|||||||
lastDownloadedSignature = signature
|
lastDownloadedSignature = signature
|
||||||
knownDownloadedHandles.insert(item.handle)
|
knownDownloadedHandles.insert(item.handle)
|
||||||
|
|
||||||
guard await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else {
|
guard let record = await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
savedShotCount += 1
|
savedShotCount += 1
|
||||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(result.filename), bytes=\(result.data.count)")
|
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(result.filename), bytes=\(result.data.count)")
|
||||||
onShotSaved?(result.filename)
|
onShotSaved?(record)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
|
private func saveToAlbum(data: Data, filename: String, reason: String) async -> TravelAlbumOTGPhotoRecord? {
|
||||||
guard let albumID else {
|
guard let albumID else {
|
||||||
OTGLog.warning(.canon, "\(reason): live transfer album not configured, skip save")
|
OTGLog.warning(.canon, "\(reason): live transfer album not configured, skip save")
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -442,15 +442,14 @@ final class CanonRemoteCaptureService {
|
|||||||
filename: filename,
|
filename: filename,
|
||||||
albumID: albumID
|
albumID: albumID
|
||||||
)
|
)
|
||||||
try photoRepository.addPhoto(
|
return try photoRepository.addPhoto(
|
||||||
albumID: albumID,
|
albumID: albumID,
|
||||||
localPath: fileURL.path,
|
localPath: fileURL.path,
|
||||||
createdAt: Date()
|
createdAt: Date()
|
||||||
)
|
)
|
||||||
return true
|
|
||||||
} catch {
|
} catch {
|
||||||
OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)")
|
OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)")
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,8 @@ final class NikonCatalogLiveCaptureService {
|
|||||||
|
|
||||||
private var albumID: Int?
|
private var albumID: Int?
|
||||||
private let photoRepository: PhotoRepositoryProtocol
|
private let photoRepository: PhotoRepositoryProtocol
|
||||||
var onShotSaved: ((String) -> Void)?
|
/// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。
|
||||||
|
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
|
||||||
|
|
||||||
private var catalogPollTimer: Timer?
|
private var catalogPollTimer: Timer?
|
||||||
private var isDownloading = false
|
private var isDownloading = false
|
||||||
@ -140,14 +141,14 @@ final class NikonCatalogLiveCaptureService {
|
|||||||
|
|
||||||
lastDownloadedSignature = objectID
|
lastDownloadedSignature = objectID
|
||||||
|
|
||||||
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
guard let record = await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||||
knownCatalogIDs.remove(objectID)
|
knownCatalogIDs.remove(objectID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedShotCount += 1
|
savedShotCount += 1
|
||||||
OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||||
onShotSaved?(filename)
|
onShotSaved?(record)
|
||||||
} catch {
|
} catch {
|
||||||
knownCatalogIDs.remove(objectID)
|
knownCatalogIDs.remove(objectID)
|
||||||
OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||||
@ -184,10 +185,10 @@ final class NikonCatalogLiveCaptureService {
|
|||||||
|
|
||||||
// MARK: - Save
|
// MARK: - Save
|
||||||
|
|
||||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
|
private func saveToAlbum(data: Data, filename: String, reason: String) async -> TravelAlbumOTGPhotoRecord? {
|
||||||
guard let albumID else {
|
guard let albumID else {
|
||||||
OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save")
|
OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save")
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -196,15 +197,14 @@ final class NikonCatalogLiveCaptureService {
|
|||||||
filename: filename,
|
filename: filename,
|
||||||
albumID: albumID
|
albumID: albumID
|
||||||
)
|
)
|
||||||
try photoRepository.addPhoto(
|
return try photoRepository.addPhoto(
|
||||||
albumID: albumID,
|
albumID: albumID,
|
||||||
localPath: fileURL.path,
|
localPath: fileURL.path,
|
||||||
createdAt: Date()
|
createdAt: Date()
|
||||||
)
|
)
|
||||||
return true
|
|
||||||
} catch {
|
} catch {
|
||||||
OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)")
|
OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)")
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,8 @@ final class SonyRemoteCaptureService {
|
|||||||
|
|
||||||
private var albumID: Int?
|
private var albumID: Int?
|
||||||
private let photoRepository: PhotoRepositoryProtocol
|
private let photoRepository: PhotoRepositoryProtocol
|
||||||
/// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。
|
/// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。
|
||||||
var onShotSaved: ((String) -> Void)?
|
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
|
||||||
|
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var propertyCheckTask: Task<Void, Never>?
|
private var propertyCheckTask: Task<Void, Never>?
|
||||||
@ -298,7 +298,7 @@ final class SonyRemoteCaptureService {
|
|||||||
filename: downloaded.filename,
|
filename: downloaded.filename,
|
||||||
albumID: albumID
|
albumID: albumID
|
||||||
)
|
)
|
||||||
try photoRepository.addPhoto(
|
let record = try photoRepository.addPhoto(
|
||||||
albumID: albumID,
|
albumID: albumID,
|
||||||
localPath: fileURL.path,
|
localPath: fileURL.path,
|
||||||
createdAt: Date()
|
createdAt: Date()
|
||||||
@ -307,7 +307,7 @@ final class SonyRemoteCaptureService {
|
|||||||
.sony,
|
.sony,
|
||||||
"Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count), path=\(fileURL.lastPathComponent)"
|
"Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count), path=\(fileURL.lastPathComponent)"
|
||||||
)
|
)
|
||||||
onShotSaved?(downloaded.filename)
|
onShotSaved?(record)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)")
|
OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)")
|
||||||
|
|||||||
@ -15,14 +15,14 @@ protocol ConnectionManagerDelegate: AnyObject {
|
|||||||
func connectionManager(_ manager: ConnectionManager, didFail error: Error)
|
func connectionManager(_ manager: ConnectionManager, didFail error: Error)
|
||||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver)
|
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver)
|
||||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager)
|
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager)
|
||||||
/// 边拍边传:单张照片已下载并写入目标相册。
|
/// 边拍边传:单张照片已下载并写入目标相册本地缓存。
|
||||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int)
|
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConnectionManagerDelegate {
|
extension ConnectionManagerDelegate {
|
||||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {}
|
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {}
|
||||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {}
|
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {}
|
||||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {}
|
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有线相机连接管理抽象,便于页面 ViewModel 测试连接与目录就绪状态。
|
/// 有线相机连接管理抽象,便于页面 ViewModel 测试连接与目录就绪状态。
|
||||||
@ -553,14 +553,14 @@ private extension ConnectionManager {
|
|||||||
nikonLiveCapture = nil
|
nikonLiveCapture = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeLiveShotSavedHandler() -> (String) -> Void {
|
private func makeLiveShotSavedHandler() -> (TravelAlbumOTGPhotoRecord) -> Void {
|
||||||
{ [weak self] filename in
|
{ [weak self] record in
|
||||||
guard let self, let albumID = self.liveTransferAlbumID else { return }
|
guard let self, let albumID = self.liveTransferAlbumID else { return }
|
||||||
|
guard record.albumId == albumID else { return }
|
||||||
self.notify {
|
self.notify {
|
||||||
self.delegate?.connectionManager(
|
self.delegate?.connectionManager(
|
||||||
self,
|
self,
|
||||||
didSaveLiveShot: filename,
|
didSaveLiveShot: record
|
||||||
albumID: albumID
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,44 @@ enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OTG 传输模式,决定新照片传入 App 后是否立即上传 OSS。
|
||||||
|
enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
||||||
|
case liveUpload
|
||||||
|
case postTransfer
|
||||||
|
|
||||||
|
/// 展示标题。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .liveUpload: return "边拍边传"
|
||||||
|
case .postTransfer: return "拍后传输"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否在新照片导入 App 后自动上传。
|
||||||
|
var shouldAutoUploadNewImports: Bool {
|
||||||
|
self == .liveUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据展示标题解析传输模式。
|
||||||
|
static func option(title: String) -> TravelAlbumOTGTransferMode? {
|
||||||
|
allCases.first { $0.title == title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OTG 指定上传弹窗选项。
|
||||||
|
enum TravelAlbumOTGSpecifyUploadOption: String, CaseIterable, Sendable {
|
||||||
|
case allPending
|
||||||
|
case todayCaptured
|
||||||
|
|
||||||
|
/// 展示标题。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .allPending: return "上传所有未上传的照片"
|
||||||
|
case .todayCaptured: return "上传所有今日拍摄的照片"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension TravelAlbumOTGPhotoItem {
|
extension TravelAlbumOTGPhotoItem {
|
||||||
/// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。
|
/// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。
|
||||||
func capturedDate() -> Date? {
|
func capturedDate() -> Date? {
|
||||||
|
|||||||
@ -89,7 +89,8 @@ protocol PhotoRepositoryProtocol {
|
|||||||
func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord]
|
func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord]
|
||||||
|
|
||||||
/// 添加一张已下载到本地的照片。
|
/// 添加一张已下载到本地的照片。
|
||||||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws
|
@discardableResult
|
||||||
|
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord
|
||||||
|
|
||||||
/// 删除指定照片及本地文件。
|
/// 删除指定照片及本地文件。
|
||||||
func deletePhoto(photoID: String, albumID: Int) throws
|
func deletePhoto(photoID: String, albumID: Int) throws
|
||||||
@ -110,12 +111,15 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一张已下载到本地的照片。
|
/// 添加一张已下载到本地的照片。
|
||||||
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws {
|
@discardableResult
|
||||||
|
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord {
|
||||||
let fileURL = URL(fileURLWithPath: localPath)
|
let fileURL = URL(fileURLWithPath: localPath)
|
||||||
let attributes = try? FileManager.default.attributesOfItem(atPath: localPath)
|
let attributes = try? FileManager.default.attributesOfItem(atPath: localPath)
|
||||||
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
let userId = storage.context.userId
|
let userId = storage.context.userId
|
||||||
guard !userId.isEmpty else { return }
|
guard !userId.isEmpty else {
|
||||||
|
throw CameraError.connectionFailed("用户信息缺失,无法保存照片")
|
||||||
|
}
|
||||||
|
|
||||||
let record = TravelAlbumOTGPhotoRecord(
|
let record = TravelAlbumOTGPhotoRecord(
|
||||||
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
|
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
|
||||||
@ -128,6 +132,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
|
|||||||
userId: userId
|
userId: userId
|
||||||
)
|
)
|
||||||
storage.upsert(record, albumId: albumID)
|
storage.upsert(record, albumId: albumID)
|
||||||
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除指定照片及本地文件。
|
/// 删除指定照片及本地文件。
|
||||||
|
|||||||
@ -18,7 +18,7 @@ enum TravelAlbumCameraImportState: Equatable {
|
|||||||
case loading
|
case loading
|
||||||
case loaded(sections: [TravelAlbumCameraImportSection])
|
case loaded(sections: [TravelAlbumCameraImportSection])
|
||||||
case importing(current: Int, total: Int)
|
case importing(current: Int, total: Int)
|
||||||
case finished(importedCount: Int, failedCount: Int)
|
case finished(importedCount: Int, failedCount: Int, importedPhotoIds: [String])
|
||||||
case failed(message: String)
|
case failed(message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,6 +180,7 @@ final class TravelAlbumCameraImportViewModel {
|
|||||||
Task {
|
Task {
|
||||||
let directory = try? storage.originalsDirectory(albumId: albumId)
|
let directory = try? storage.originalsDirectory(albumId: albumId)
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
|
var importedPhotoIds: [String] = []
|
||||||
|
|
||||||
for (index, object) in selected.enumerated() {
|
for (index, object) in selected.enumerated() {
|
||||||
defer { state = .importing(current: index + 1, total: selected.count) }
|
defer { state = .importing(current: index + 1, total: selected.count) }
|
||||||
@ -204,6 +205,7 @@ final class TravelAlbumCameraImportViewModel {
|
|||||||
)
|
)
|
||||||
storage.upsert(record, albumId: albumId)
|
storage.upsert(record, albumId: albumId)
|
||||||
existingPhotoIds.insert(object.id)
|
existingPhotoIds.insert(object.id)
|
||||||
|
importedPhotoIds.append(record.id)
|
||||||
importedCount += 1
|
importedCount += 1
|
||||||
} catch {
|
} catch {
|
||||||
failedPhotoIds.insert(object.id)
|
failedPhotoIds.insert(object.id)
|
||||||
@ -212,7 +214,11 @@ final class TravelAlbumCameraImportViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedPhotoIds.subtract(existingPhotoIds)
|
selectedPhotoIds.subtract(existingPhotoIds)
|
||||||
state = .finished(importedCount: importedCount, failedCount: failedPhotoIds.count)
|
state = .finished(
|
||||||
|
importedCount: importedCount,
|
||||||
|
failedCount: failedPhotoIds.count,
|
||||||
|
importedPhotoIds: importedPhotoIds
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,28 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// 手机相册导入到 OTG 本地缓存的图片数据。
|
||||||
|
struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
|
||||||
|
let data: Data
|
||||||
|
let fileName: String
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
/// 创建手机相册导入图片。
|
||||||
|
init(data: Data, fileName: String, createdAt: Date = Date()) {
|
||||||
|
self.data = data
|
||||||
|
self.fileName = fileName
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
|
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class WiredCameraTransferViewModel {
|
final class WiredCameraTransferViewModel {
|
||||||
|
private static let transferModeDefaultsKey = "travelAlbum.otg.transferMode"
|
||||||
|
|
||||||
let albumId: Int
|
let albumId: Int
|
||||||
let albumTitle: String
|
let albumTitle: String
|
||||||
let headerPhone: String
|
let headerPhone: String
|
||||||
@ -38,14 +55,19 @@ final class WiredCameraTransferViewModel {
|
|||||||
didSet { notifyStateChanged() }
|
didSet { notifyStateChanged() }
|
||||||
}
|
}
|
||||||
private(set) var selectedTimeSlotId: String? {
|
private(set) var selectedTimeSlotId: String? {
|
||||||
didSet { notifyStateChanged() }
|
didSet {
|
||||||
|
guard !suppressSelectedTimeSlotNotification else { return }
|
||||||
|
notifyStateChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private(set) var sonyMTPHint: String?
|
private(set) var sonyMTPHint: String?
|
||||||
private(set) var isContentCatalogReady = false
|
private(set) var isContentCatalogReady = false
|
||||||
|
|
||||||
let retouchOption = "不修图"
|
let retouchOption = "不修图"
|
||||||
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
|
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
|
||||||
private(set) var transferModeOption = "边拍边传"
|
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
|
||||||
|
didSet { notifyStateChanged() }
|
||||||
|
}
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@ -56,10 +78,13 @@ final class WiredCameraTransferViewModel {
|
|||||||
private let repository: TravelAlbumOTGPhotoRepository
|
private let repository: TravelAlbumOTGPhotoRepository
|
||||||
private let uploader: any TravelAlbumOTGUploading
|
private let uploader: any TravelAlbumOTGUploading
|
||||||
private let appStore: AppStore
|
private let appStore: AppStore
|
||||||
|
private let userDefaults: UserDefaults
|
||||||
|
|
||||||
private var currentDriver: CameraDriver?
|
private var currentDriver: CameraDriver?
|
||||||
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
|
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
|
||||||
private var isUploading = false
|
private var isUploading = false
|
||||||
|
private var queuedAutoUploadPhotoIds: Set<String> = []
|
||||||
|
private var suppressSelectedTimeSlotNotification = false
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(
|
init(
|
||||||
@ -70,7 +95,8 @@ final class WiredCameraTransferViewModel {
|
|||||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||||
appStore: AppStore = .shared
|
appStore: AppStore = .shared,
|
||||||
|
userDefaults: UserDefaults = .standard
|
||||||
) {
|
) {
|
||||||
self.albumId = albumId
|
self.albumId = albumId
|
||||||
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
|
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
|
||||||
@ -81,6 +107,8 @@ final class WiredCameraTransferViewModel {
|
|||||||
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
|
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
|
||||||
self.uploader = uploader ?? TravelAlbumOTGUploader()
|
self.uploader = uploader ?? TravelAlbumOTGUploader()
|
||||||
self.appStore = appStore
|
self.appStore = appStore
|
||||||
|
self.userDefaults = userDefaults
|
||||||
|
self.transferMode = Self.persistedTransferMode(in: userDefaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机状态文案。
|
/// 相机状态文案。
|
||||||
@ -92,8 +120,8 @@ final class WiredCameraTransferViewModel {
|
|||||||
return "正在搜索"
|
return "正在搜索"
|
||||||
case .connecting:
|
case .connecting:
|
||||||
return "正在连接"
|
return "正在连接"
|
||||||
case .connected:
|
case .connected(_, let name):
|
||||||
return "已连接"
|
return "\(name)已连接"
|
||||||
case .failed:
|
case .failed:
|
||||||
return "连接失败"
|
return "连接失败"
|
||||||
}
|
}
|
||||||
@ -111,19 +139,22 @@ final class WiredCameraTransferViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备名称。
|
/// 当前手机型号名称。
|
||||||
var deviceModelName: String {
|
var deviceModelName: String {
|
||||||
switch connectionState {
|
Self.currentPhoneModelName()
|
||||||
case .connecting(let name), .connected(_, let name):
|
|
||||||
return name
|
|
||||||
default:
|
|
||||||
return "设备存储"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前设备容量占位文案。
|
/// 当前手机可用存储空间。
|
||||||
var availableStorageText: String {
|
var availableStorageText: String {
|
||||||
"-- GB 可用"
|
Self.availableStorageText()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否已连接相机。
|
||||||
|
var isCameraConnected: Bool {
|
||||||
|
if case .connected = connectionState {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tab 统计。
|
/// Tab 统计。
|
||||||
@ -173,6 +204,21 @@ final class WiredCameraTransferViewModel {
|
|||||||
selectedPhotoIds.intersection(selectableVisiblePhotoIds).count
|
selectedPhotoIds.intersection(selectableVisiblePhotoIds).count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 传输模式展示文案。
|
||||||
|
var transferModeOption: String {
|
||||||
|
transferMode.title
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定上传弹窗选项。
|
||||||
|
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
|
||||||
|
TravelAlbumOTGSpecifyUploadOption.allCases
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否可打开指定上传选项。
|
||||||
|
var canOpenSpecifyUploadChooser: Bool {
|
||||||
|
!selectUploadMode
|
||||||
|
}
|
||||||
|
|
||||||
/// 绑定连接管理器并启动发现。
|
/// 绑定连接管理器并启动发现。
|
||||||
func start() {
|
func start() {
|
||||||
connectionManager.configureLiveTransfer(albumID: albumId)
|
connectionManager.configureLiveTransfer(albumID: albumId)
|
||||||
@ -227,10 +273,25 @@ final class WiredCameraTransferViewModel {
|
|||||||
selectedTimeSlotId = id
|
selectedTimeSlotId = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 同步主列表当前可见时间槽,只更新左侧时间轴选中态,不触发整页重新绑定。
|
||||||
|
func syncVisibleTimeSlot(id: String) {
|
||||||
|
guard selectedTimeSlotId != id,
|
||||||
|
photoSections.contains(where: { $0.slotId == id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressSelectedTimeSlotNotification = true
|
||||||
|
selectedTimeSlotId = id
|
||||||
|
suppressSelectedTimeSlotNotification = false
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换传输模式。
|
/// 切换传输模式。
|
||||||
func selectTransferMode(_ option: String) {
|
func selectTransferMode(_ option: String) {
|
||||||
guard option == "边拍边传" || option == "拍后传输" else { return }
|
guard let mode = TravelAlbumOTGTransferMode.option(title: option),
|
||||||
transferModeOption = option
|
mode != transferMode else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transferMode = mode
|
||||||
|
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换上传格式。
|
/// 切换上传格式。
|
||||||
@ -254,10 +315,15 @@ final class WiredCameraTransferViewModel {
|
|||||||
uploadSelectedTransferPhotos()
|
uploadSelectedTransferPhotos()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 指定上传:默认上传所有未上传照片。
|
/// 选择指定上传选项后开始上传。
|
||||||
func onSpecifyUploadButtonClick() {
|
func onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) {
|
||||||
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
|
guard canOpenSpecifyUploadChooser else { return }
|
||||||
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
|
switch option {
|
||||||
|
case .allPending:
|
||||||
|
uploadAllNotUploadedPhotos()
|
||||||
|
case .todayCaptured:
|
||||||
|
uploadAllTodayCapturedPhotos()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建相机历史照片选择导入页 ViewModel。
|
/// 创建相机历史照片选择导入页 ViewModel。
|
||||||
@ -288,6 +354,49 @@ final class WiredCameraTransferViewModel {
|
|||||||
connectionManager.suspendLiveTransfer()
|
connectionManager.suspendLiveTransfer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理本次新传入 App 的照片,按当前传输模式决定是否自动上传。
|
||||||
|
func handleNewlyImportedPhotoIds(_ photoIds: [String]) {
|
||||||
|
let ids = Set(photoIds)
|
||||||
|
loadPersistedPhotos()
|
||||||
|
guard !ids.isEmpty, transferMode.shouldAutoUploadNewImports else { return }
|
||||||
|
startUploadByIds(
|
||||||
|
ids,
|
||||||
|
emptyMessage: "暂无可自动上传的照片",
|
||||||
|
queueIfBusy: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将手机相册选择的图片导入当前 OTG 相册本地缓存。
|
||||||
|
func importPhoneAlbumImages(_ items: [TravelAlbumPhoneAlbumImportItem]) -> (importedCount: Int, failedCount: Int, importedPhotoIds: [String]) {
|
||||||
|
guard !items.isEmpty else { return (0, 0, []) }
|
||||||
|
var importedPhotoIds: [String] = []
|
||||||
|
var failedCount = 0
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
do {
|
||||||
|
let fileURL = try storage.writeImage(item.data, filename: item.fileName, albumId: albumId)
|
||||||
|
let record = TravelAlbumOTGPhotoRecord(
|
||||||
|
id: "phone_album_\(UUID().uuidString)",
|
||||||
|
fileName: fileURL.lastPathComponent,
|
||||||
|
localPath: storage.relativePath(for: fileURL, albumId: albumId),
|
||||||
|
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(item.createdAt),
|
||||||
|
fileSizeBytes: Int64(item.data.count),
|
||||||
|
status: .pending,
|
||||||
|
albumId: albumId,
|
||||||
|
userId: appStore.userId
|
||||||
|
)
|
||||||
|
storage.upsert(record, albumId: albumId)
|
||||||
|
importedPhotoIds.append(record.id)
|
||||||
|
} catch {
|
||||||
|
failedCount += 1
|
||||||
|
OTGLog.error(.connection, "phone album import failed: \(item.fileName) \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewlyImportedPhotoIds(importedPhotoIds)
|
||||||
|
return (importedPhotoIds.count, failedCount, importedPhotoIds)
|
||||||
|
}
|
||||||
|
|
||||||
/// 选择或取消选择照片。
|
/// 选择或取消选择照片。
|
||||||
func toggleTransferPhotoSelection(photoId: String) {
|
func toggleTransferPhotoSelection(photoId: String) {
|
||||||
guard selectUploadMode,
|
guard selectUploadMode,
|
||||||
@ -325,6 +434,22 @@ final class WiredCameraTransferViewModel {
|
|||||||
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
|
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func uploadAllNotUploadedPhotos() {
|
||||||
|
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
|
||||||
|
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAllTodayCapturedPhotos() {
|
||||||
|
let ids = Set(photos.filter { photo in
|
||||||
|
guard photo.isNotUploaded,
|
||||||
|
let capturedDate = photo.capturedDate() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Calendar.current.isDateInToday(capturedDate)
|
||||||
|
}.map(\.id))
|
||||||
|
startUploadByIds(ids, emptyMessage: "暂无今日拍摄且未上传的照片")
|
||||||
|
}
|
||||||
|
|
||||||
/// 重试上传单张照片。
|
/// 重试上传单张照片。
|
||||||
func retryPhoto(photoId: String) {
|
func retryPhoto(photoId: String) {
|
||||||
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
|
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
|
||||||
@ -375,9 +500,17 @@ final class WiredCameraTransferViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
|
private func startUploadByIds(
|
||||||
|
_ ids: Set<String>,
|
||||||
|
emptyMessage: String,
|
||||||
|
queueIfBusy: Bool = false
|
||||||
|
) {
|
||||||
guard !isUploading else {
|
guard !isUploading else {
|
||||||
showMessage("正在上传,请稍候")
|
if queueIfBusy {
|
||||||
|
queuedAutoUploadPhotoIds.formUnion(ids)
|
||||||
|
} else {
|
||||||
|
showMessage("正在上传,请稍候")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let targets = resolveUploadTargets(ids)
|
let targets = resolveUploadTargets(ids)
|
||||||
@ -386,18 +519,32 @@ final class WiredCameraTransferViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
isUploading = true
|
isUploading = true
|
||||||
showMessage("开始上传 \(targets.count) 张照片")
|
|
||||||
Task {
|
Task {
|
||||||
for id in targets {
|
for id in targets {
|
||||||
await uploadPhoto(id: id)
|
await uploadPhoto(id: id)
|
||||||
}
|
}
|
||||||
isUploading = false
|
isUploading = false
|
||||||
notifyStateChanged()
|
notifyStateChanged()
|
||||||
|
uploadQueuedAutoImportsIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func uploadQueuedAutoImportsIfNeeded() {
|
||||||
|
let ids = queuedAutoUploadPhotoIds
|
||||||
|
queuedAutoUploadPhotoIds = []
|
||||||
|
guard !ids.isEmpty else { return }
|
||||||
|
startUploadByIds(
|
||||||
|
ids,
|
||||||
|
emptyMessage: "暂无可自动上传的照片",
|
||||||
|
queueIfBusy: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
|
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
|
||||||
ids.filter { persistedRecordsById[$0] != nil }
|
ids.filter { id in
|
||||||
|
guard let record = persistedRecordsById[id] else { return false }
|
||||||
|
return record.status == .pending || record.status == .failed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadPhoto(id: String) async {
|
private func uploadPhoto(id: String) async {
|
||||||
@ -474,6 +621,88 @@ final class WiredCameraTransferViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func persistedTransferMode(in userDefaults: UserDefaults) -> TravelAlbumOTGTransferMode {
|
||||||
|
guard let rawValue = userDefaults.string(forKey: transferModeDefaultsKey),
|
||||||
|
let mode = TravelAlbumOTGTransferMode(rawValue: rawValue) else {
|
||||||
|
return .liveUpload
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func currentPhoneModelName() -> String {
|
||||||
|
let identifier = machineIdentifier()
|
||||||
|
if identifier == "i386" || identifier == "x86_64" || identifier == "arm64" {
|
||||||
|
return "iPhone 模拟器"
|
||||||
|
}
|
||||||
|
if let modelName = iPhoneModelNames[identifier] {
|
||||||
|
return modelName
|
||||||
|
}
|
||||||
|
return identifier.hasPrefix("iPhone") ? identifier : "iPhone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func machineIdentifier() -> String {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
return withUnsafePointer(to: &systemInfo.machine) { pointer in
|
||||||
|
pointer.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||||
|
String(validatingUTF8: $0) ?? "iPhone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func availableStorageText() -> String {
|
||||||
|
guard let bytes = availableStorageBytes() else {
|
||||||
|
return "-- GB 可用"
|
||||||
|
}
|
||||||
|
let gigabytes = Double(bytes) / 1_073_741_824.0
|
||||||
|
if gigabytes >= 100 {
|
||||||
|
return "\(Int(gigabytes.rounded(.down))) GB 可用"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f GB 可用", gigabytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func availableStorageBytes() -> Int64? {
|
||||||
|
let homeURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
||||||
|
if let values = try? homeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
|
||||||
|
let capacity = values.volumeAvailableCapacityForImportantUsage {
|
||||||
|
return capacity
|
||||||
|
}
|
||||||
|
if let attributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
|
||||||
|
let freeSize = attributes[.systemFreeSize] as? NSNumber {
|
||||||
|
return freeSize.int64Value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let iPhoneModelNames: [String: String] = [
|
||||||
|
"iPhone12,1": "iPhone 11",
|
||||||
|
"iPhone12,3": "iPhone 11 Pro",
|
||||||
|
"iPhone12,5": "iPhone 11 Pro Max",
|
||||||
|
"iPhone12,8": "iPhone SE 2",
|
||||||
|
"iPhone13,1": "iPhone 12 mini",
|
||||||
|
"iPhone13,2": "iPhone 12",
|
||||||
|
"iPhone13,3": "iPhone 12 Pro",
|
||||||
|
"iPhone13,4": "iPhone 12 Pro Max",
|
||||||
|
"iPhone14,2": "iPhone 13 Pro",
|
||||||
|
"iPhone14,3": "iPhone 13 Pro Max",
|
||||||
|
"iPhone14,4": "iPhone 13 mini",
|
||||||
|
"iPhone14,5": "iPhone 13",
|
||||||
|
"iPhone14,6": "iPhone SE 3",
|
||||||
|
"iPhone14,7": "iPhone 14",
|
||||||
|
"iPhone14,8": "iPhone 14 Plus",
|
||||||
|
"iPhone15,2": "iPhone 14 Pro",
|
||||||
|
"iPhone15,3": "iPhone 14 Pro Max",
|
||||||
|
"iPhone15,4": "iPhone 15",
|
||||||
|
"iPhone15,5": "iPhone 15 Plus",
|
||||||
|
"iPhone16,1": "iPhone 15 Pro",
|
||||||
|
"iPhone16,2": "iPhone 15 Pro Max",
|
||||||
|
"iPhone17,1": "iPhone 16 Pro",
|
||||||
|
"iPhone17,2": "iPhone 16 Pro Max",
|
||||||
|
"iPhone17,3": "iPhone 16",
|
||||||
|
"iPhone17,4": "iPhone 16 Plus",
|
||||||
|
"iPhone17,5": "iPhone 16e",
|
||||||
|
]
|
||||||
|
|
||||||
private func showMessage(_ message: String) {
|
private func showMessage(_ message: String) {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
onShowMessage?(message)
|
onShowMessage?(message)
|
||||||
@ -507,9 +736,9 @@ extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
|
|||||||
showMessage("长时间未找到相机,请尝试重新插拔连接线")
|
showMessage("长时间未找到相机,请尝试重新插拔连接线")
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {
|
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {
|
||||||
guard albumID == albumId else { return }
|
guard record.albumId == albumId else { return }
|
||||||
loadPersistedPhotos()
|
handleNewlyImportedPhotoIds([record.id])
|
||||||
onLiveShotSaved?(filename)
|
onLiveShotSaved?(record.fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let viewModel: TravelAlbumCameraImportViewModel
|
private let viewModel: TravelAlbumCameraImportViewModel
|
||||||
var onImportFinished: ((Int, Int) -> Void)?
|
var onImportFinished: ((Int, Int, [String]) -> Void)?
|
||||||
var onImportCancelled: (() -> Void)?
|
var onImportCancelled: (() -> Void)?
|
||||||
|
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
@ -193,11 +193,11 @@ final class TravelAlbumCameraImportViewController: UIViewController {
|
|||||||
navigationItem.leftBarButtonItem?.isEnabled = false
|
navigationItem.leftBarButtonItem?.isEnabled = false
|
||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
|
|
||||||
case .finished(let importedCount, let failedCount):
|
case .finished(let importedCount, let failedCount, let importedPhotoIds):
|
||||||
isModalInPresentation = false
|
isModalInPresentation = false
|
||||||
activityIndicator.stopAnimating()
|
activityIndicator.stopAnimating()
|
||||||
dismiss(animated: true) { [weak self] in
|
dismiss(animated: true) { [weak self] in
|
||||||
self?.onImportFinished?(importedCount, failedCount)
|
self?.onImportFinished?(importedCount, failedCount, importedPhotoIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
|
|||||||
@ -4,8 +4,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
|
import PhotosUI
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
private enum WiredTransferMetrics {
|
private enum WiredTransferMetrics {
|
||||||
static let sidebarWidth: CGFloat = 76
|
static let sidebarWidth: CGFloat = 76
|
||||||
@ -43,7 +45,11 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
private let retouchButton = UIButton(type: .system)
|
private let retouchButton = UIButton(type: .system)
|
||||||
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 modeChevronView = UIImageView()
|
||||||
|
private let settingsStatsDivider = UIView()
|
||||||
private let statsCard = UIStackView()
|
private let statsCard = UIStackView()
|
||||||
|
private let firstStatsDivider = UIView()
|
||||||
|
private let secondStatsDivider = UIView()
|
||||||
private let photoPanelView = UIView()
|
private let photoPanelView = UIView()
|
||||||
private let selectAllBar = UIControl()
|
private let selectAllBar = UIControl()
|
||||||
private let selectAllIconView = UIImageView()
|
private let selectAllIconView = UIImageView()
|
||||||
@ -61,9 +67,12 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
private var pendingScrollSlotId: String?
|
private var pendingScrollSlotId: String?
|
||||||
private var selectAllBarHeightConstraint: Constraint?
|
private var selectAllBarHeightConstraint: Constraint?
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
|
private let bottomButtonsStack = UIStackView()
|
||||||
private let batchButton = UIButton(type: .system)
|
private let batchButton = UIButton(type: .system)
|
||||||
private let historyImportButton = UIButton(type: .system)
|
private let historyImportButton = UIButton(type: .system)
|
||||||
|
private let albumImportButton = UIButton(type: .system)
|
||||||
private let specifyButton = UIButton(type: .system)
|
private let specifyButton = UIButton(type: .system)
|
||||||
|
private var isHistoryImportButtonVisible = false
|
||||||
|
|
||||||
init(viewModel: WiredCameraTransferViewModel) {
|
init(viewModel: WiredCameraTransferViewModel) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
@ -121,9 +130,13 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
storageTitleLabel.textColor = AppColor.textPrimary
|
storageTitleLabel.textColor = AppColor.textPrimary
|
||||||
storageTitleLabel.textAlignment = .center
|
storageTitleLabel.textAlignment = .center
|
||||||
|
storageTitleLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
storageTitleLabel.minimumScaleFactor = 0.72
|
||||||
storageValueLabel.font = .systemFont(ofSize: 10)
|
storageValueLabel.font = .systemFont(ofSize: 10)
|
||||||
storageValueLabel.textColor = AppColor.primary
|
storageValueLabel.textColor = AppColor.primary
|
||||||
storageValueLabel.textAlignment = .center
|
storageValueLabel.textAlignment = .center
|
||||||
|
storageValueLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
storageValueLabel.minimumScaleFactor = 0.72
|
||||||
|
|
||||||
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
statusLabel.layer.cornerRadius = 4
|
statusLabel.layer.cornerRadius = 4
|
||||||
@ -139,13 +152,25 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
|
|
||||||
chipsStack.axis = .horizontal
|
chipsStack.axis = .horizontal
|
||||||
chipsStack.spacing = 6
|
chipsStack.spacing = 6
|
||||||
|
chipsStack.distribution = .fillEqually
|
||||||
[retouchButton, formatButton, modeButton].forEach {
|
[retouchButton, formatButton, modeButton].forEach {
|
||||||
configureChipButton($0)
|
configureChipButton($0)
|
||||||
chipsStack.addArrangedSubview($0)
|
chipsStack.addArrangedSubview($0)
|
||||||
}
|
}
|
||||||
|
retouchButton.isUserInteractionEnabled = false
|
||||||
|
formatButton.isUserInteractionEnabled = false
|
||||||
|
modeButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 32)
|
||||||
|
modeChevronView.image = UIImage(systemName: "chevron.down")?
|
||||||
|
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
|
||||||
|
modeChevronView.tintColor = AppColor.textTertiary
|
||||||
|
modeChevronView.contentMode = .scaleAspectFit
|
||||||
|
modeChevronView.isUserInteractionEnabled = false
|
||||||
|
settingsStatsDivider.backgroundColor = AppColor.border
|
||||||
|
|
||||||
statsCard.axis = .horizontal
|
statsCard.axis = .horizontal
|
||||||
statsCard.distribution = .fillEqually
|
statsCard.distribution = .fillEqually
|
||||||
|
firstStatsDivider.backgroundColor = AppColor.border
|
||||||
|
secondStatsDivider.backgroundColor = AppColor.border
|
||||||
|
|
||||||
photoPanelView.backgroundColor = .white
|
photoPanelView.backgroundColor = .white
|
||||||
selectAllBar.backgroundColor = .white
|
selectAllBar.backgroundColor = .white
|
||||||
@ -200,9 +225,15 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
emptyLabel.textAlignment = .center
|
emptyLabel.textAlignment = .center
|
||||||
|
|
||||||
bottomBar.backgroundColor = .white
|
bottomBar.backgroundColor = .white
|
||||||
|
bottomButtonsStack.axis = .horizontal
|
||||||
|
bottomButtonsStack.spacing = 8
|
||||||
|
bottomButtonsStack.distribution = .fillEqually
|
||||||
configureBottomButton(batchButton, title: "批量上传", filled: true)
|
configureBottomButton(batchButton, title: "批量上传", filled: true)
|
||||||
configureBottomButton(historyImportButton, title: "历史导入", filled: false)
|
configureBottomButton(historyImportButton, title: "历史导入", filled: false)
|
||||||
|
configureBottomButton(albumImportButton, title: "相册导入", filled: false)
|
||||||
configureBottomButton(specifyButton, title: "指定上传", filled: false)
|
configureBottomButton(specifyButton, title: "指定上传", filled: false)
|
||||||
|
historyImportButton.isHidden = true
|
||||||
|
historyImportButton.alpha = 0
|
||||||
|
|
||||||
view.addSubview(headerView)
|
view.addSubview(headerView)
|
||||||
headerView.addSubview(backButton)
|
headerView.addSubview(backButton)
|
||||||
@ -216,7 +247,10 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
statusCard.addSubview(refreshButton)
|
statusCard.addSubview(refreshButton)
|
||||||
statusCard.addSubview(helpLabel)
|
statusCard.addSubview(helpLabel)
|
||||||
statusCard.addSubview(chipsStack)
|
statusCard.addSubview(chipsStack)
|
||||||
|
statusCard.addSubview(settingsStatsDivider)
|
||||||
statusCard.addSubview(statsCard)
|
statusCard.addSubview(statsCard)
|
||||||
|
statsCard.addSubview(firstStatsDivider)
|
||||||
|
statsCard.addSubview(secondStatsDivider)
|
||||||
view.addSubview(photoPanelView)
|
view.addSubview(photoPanelView)
|
||||||
photoPanelView.addSubview(selectAllBar)
|
photoPanelView.addSubview(selectAllBar)
|
||||||
selectAllBar.addSubview(selectAllIconView)
|
selectAllBar.addSubview(selectAllIconView)
|
||||||
@ -230,9 +264,12 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
listContainerView.addSubview(expandSidebarButton)
|
listContainerView.addSubview(expandSidebarButton)
|
||||||
view.addSubview(emptyLabel)
|
view.addSubview(emptyLabel)
|
||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
bottomBar.addSubview(batchButton)
|
bottomBar.addSubview(bottomButtonsStack)
|
||||||
bottomBar.addSubview(historyImportButton)
|
modeButton.addSubview(modeChevronView)
|
||||||
bottomBar.addSubview(specifyButton)
|
bottomButtonsStack.addArrangedSubview(batchButton)
|
||||||
|
bottomButtonsStack.addArrangedSubview(specifyButton)
|
||||||
|
bottomButtonsStack.addArrangedSubview(albumImportButton)
|
||||||
|
bottomButtonsStack.addArrangedSubview(historyImportButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
@ -259,8 +296,9 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
make.leading.trailing.equalToSuperview().inset(16)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
}
|
}
|
||||||
storagePanel.snp.makeConstraints { make in
|
storagePanel.snp.makeConstraints { make in
|
||||||
make.top.leading.equalToSuperview().offset(14)
|
make.top.equalToSuperview().offset(14)
|
||||||
make.width.greaterThanOrEqualTo(76)
|
make.leading.equalToSuperview().offset(14)
|
||||||
|
make.width.equalTo(96)
|
||||||
make.height.equalTo(76)
|
make.height.equalTo(76)
|
||||||
}
|
}
|
||||||
storageTitleLabel.snp.makeConstraints { make in
|
storageTitleLabel.snp.makeConstraints { make in
|
||||||
@ -273,7 +311,8 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
statusLabel.snp.makeConstraints { make in
|
statusLabel.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(16)
|
make.top.equalToSuperview().offset(16)
|
||||||
make.leading.equalTo(storagePanel.snp.trailing).offset(10)
|
make.leading.equalTo(storagePanel.snp.trailing).offset(8)
|
||||||
|
make.trailing.lessThanOrEqualTo(refreshButton.snp.leading).offset(-6)
|
||||||
make.height.equalTo(22)
|
make.height.equalTo(22)
|
||||||
make.width.greaterThanOrEqualTo(84)
|
make.width.greaterThanOrEqualTo(84)
|
||||||
}
|
}
|
||||||
@ -281,41 +320,52 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
make.centerY.equalTo(statusLabel)
|
make.centerY.equalTo(statusLabel)
|
||||||
make.trailing.equalToSuperview().offset(-14)
|
make.trailing.equalToSuperview().offset(-14)
|
||||||
make.height.equalTo(34)
|
make.height.equalTo(34)
|
||||||
make.width.greaterThanOrEqualTo(72)
|
make.width.greaterThanOrEqualTo(64)
|
||||||
}
|
}
|
||||||
helpLabel.snp.makeConstraints { make in
|
helpLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(statusLabel.snp.bottom).offset(4)
|
make.top.equalTo(statusLabel.snp.bottom).offset(4)
|
||||||
make.leading.equalTo(statusLabel)
|
make.leading.equalTo(statusLabel)
|
||||||
make.trailing.equalTo(refreshButton)
|
make.trailing.equalToSuperview().offset(-14)
|
||||||
}
|
}
|
||||||
chipsStack.snp.makeConstraints { make in
|
chipsStack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(storagePanel.snp.bottom).offset(12)
|
make.top.equalTo(storagePanel.snp.bottom).offset(12)
|
||||||
make.leading.trailing.equalToSuperview().inset(14)
|
make.leading.trailing.equalToSuperview().inset(14)
|
||||||
make.height.equalTo(34)
|
make.height.equalTo(34)
|
||||||
}
|
}
|
||||||
|
modeChevronView.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().offset(-10)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(16)
|
||||||
|
}
|
||||||
|
settingsStatsDivider.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(chipsStack.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(1 / UIScreen.main.scale)
|
||||||
|
}
|
||||||
statsCard.snp.makeConstraints { make in
|
statsCard.snp.makeConstraints { make in
|
||||||
make.top.equalTo(chipsStack.snp.bottom).offset(14)
|
make.top.equalTo(settingsStatsDivider.snp.bottom).offset(2)
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
make.height.equalTo(68)
|
make.height.equalTo(68)
|
||||||
}
|
}
|
||||||
|
firstStatsDivider.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(1 / UIScreen.main.scale)
|
||||||
|
make.top.bottom.equalToSuperview().inset(14)
|
||||||
|
make.centerX.equalToSuperview().multipliedBy(2.0 / 3.0)
|
||||||
|
}
|
||||||
|
secondStatsDivider.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(1 / UIScreen.main.scale)
|
||||||
|
make.top.bottom.equalToSuperview().inset(14)
|
||||||
|
make.centerX.equalToSuperview().multipliedBy(4.0 / 3.0)
|
||||||
|
}
|
||||||
bottomBar.snp.makeConstraints { make in
|
bottomBar.snp.makeConstraints { make in
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
batchButton.snp.makeConstraints { make in
|
bottomButtonsStack.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(12)
|
make.top.equalToSuperview().offset(12)
|
||||||
make.leading.equalToSuperview().offset(16)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
make.height.equalTo(44)
|
make.height.equalTo(44)
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
}
|
}
|
||||||
historyImportButton.snp.makeConstraints { make in
|
|
||||||
make.top.height.width.equalTo(batchButton)
|
|
||||||
make.leading.equalTo(batchButton.snp.trailing).offset(8)
|
|
||||||
}
|
|
||||||
specifyButton.snp.makeConstraints { make in
|
|
||||||
make.top.height.width.equalTo(batchButton)
|
|
||||||
make.leading.equalTo(historyImportButton.snp.trailing).offset(8)
|
|
||||||
make.trailing.equalToSuperview().offset(-16)
|
|
||||||
}
|
|
||||||
photoPanelView.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()
|
make.leading.trailing.equalToSuperview()
|
||||||
@ -366,13 +416,11 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
viewModel.onShowMessage = { [weak self] message in
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
viewModel.onLiveShotSaved = { [weak self] filename in
|
|
||||||
Task { @MainActor in self?.showToast("已保存 \(filename)") }
|
|
||||||
}
|
|
||||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||||
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)
|
||||||
|
albumImportButton.addTarget(self, action: #selector(albumImportTapped), 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)
|
selectAllBar.addTarget(self, action: #selector(selectAllTapped), for: .touchUpInside)
|
||||||
expandSidebarButton.addTarget(self, action: #selector(toggleSidebarTapped), for: .touchUpInside)
|
expandSidebarButton.addTarget(self, action: #selector(toggleSidebarTapped), for: .touchUpInside)
|
||||||
@ -381,12 +429,8 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
self?.pendingScrollSlotId = slotId
|
self?.pendingScrollSlotId = slotId
|
||||||
self?.viewModel.selectTimeSlot(id: slotId)
|
self?.viewModel.selectTimeSlot(id: slotId)
|
||||||
}
|
}
|
||||||
formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in
|
modeButton.menu = UIMenu(children: TravelAlbumOTGTransferMode.allCases.map { mode in
|
||||||
UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) }
|
UIAction(title: mode.title) { [weak self] _ in self?.viewModel.selectTransferMode(mode.title) }
|
||||||
})
|
|
||||||
formatButton.showsMenuAsPrimaryAction = true
|
|
||||||
modeButton.menu = UIMenu(children: ["边拍边传", "拍后传输"].map { option in
|
|
||||||
UIAction(title: option) { [weak self] _ in self?.viewModel.selectTransferMode(option) }
|
|
||||||
})
|
})
|
||||||
modeButton.showsMenuAsPrimaryAction = true
|
modeButton.showsMenuAsPrimaryAction = true
|
||||||
helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped)))
|
helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped)))
|
||||||
@ -403,7 +447,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
|
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
|
||||||
|
|
||||||
retouchButton.setTitle(viewModel.retouchOption, for: .normal)
|
retouchButton.setTitle(viewModel.retouchOption, for: .normal)
|
||||||
formatButton.setTitle(viewModel.photoFormatOption.rawValue, for: .normal)
|
formatButton.setTitle("JPG", for: .normal)
|
||||||
modeButton.setTitle(viewModel.transferModeOption, for: .normal)
|
modeButton.setTitle(viewModel.transferModeOption, for: .normal)
|
||||||
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
|
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
|
||||||
|
|
||||||
@ -435,6 +479,37 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
batchButton.setTitle("批量上传", for: .normal)
|
batchButton.setTitle("批量上传", for: .normal)
|
||||||
}
|
}
|
||||||
historyImportButton.setTitle("历史导入", for: .normal)
|
historyImportButton.setTitle("历史导入", for: .normal)
|
||||||
|
albumImportButton.setTitle("相册导入", for: .normal)
|
||||||
|
specifyButton.isEnabled = viewModel.canOpenSpecifyUploadChooser
|
||||||
|
specifyButton.alpha = specifyButton.isEnabled ? 1 : 0.55
|
||||||
|
updateHistoryImportButtonVisibility(viewModel.isCameraConnected, animated: view.window != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHistoryImportButtonVisibility(_ visible: Bool, animated: Bool) {
|
||||||
|
guard visible != isHistoryImportButtonVisible else { return }
|
||||||
|
isHistoryImportButtonVisible = visible
|
||||||
|
if visible {
|
||||||
|
historyImportButton.isHidden = false
|
||||||
|
historyImportButton.alpha = 0
|
||||||
|
}
|
||||||
|
let changes = {
|
||||||
|
self.historyImportButton.alpha = visible ? 1 : 0
|
||||||
|
if !visible {
|
||||||
|
self.historyImportButton.isHidden = true
|
||||||
|
}
|
||||||
|
self.bottomBar.layoutIfNeeded()
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
guard animated else {
|
||||||
|
changes()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.25,
|
||||||
|
delay: 0,
|
||||||
|
options: [.beginFromCurrentState, .curveEaseInOut],
|
||||||
|
animations: changes
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSelectAllBar() {
|
private func updateSelectAllBar() {
|
||||||
@ -488,7 +563,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
let selected = viewModel.selectedPhotoIds.contains(item.id)
|
let selected = viewModel.selectedPhotoIds.contains(item.id)
|
||||||
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
|
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
|
||||||
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
|
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
|
||||||
cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) }
|
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reconfigureVisiblePhotoCells() {
|
private func reconfigureVisiblePhotoCells() {
|
||||||
@ -501,6 +576,31 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncSidebarSelectionWithCurrentScrollPosition() {
|
||||||
|
guard !currentSections.isEmpty else { return }
|
||||||
|
let visibleTopY = collectionView.contentOffset.y
|
||||||
|
+ collectionView.adjustedContentInset.top
|
||||||
|
+ WiredTransferMetrics.sectionHeaderHeight
|
||||||
|
let visibleItems = collectionView.indexPathsForVisibleItems.compactMap { indexPath -> (IndexPath, CGRect)? in
|
||||||
|
guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { return nil }
|
||||||
|
return (indexPath, attributes.frame)
|
||||||
|
}
|
||||||
|
guard let target = visibleItems
|
||||||
|
.filter({ $0.1.maxY >= visibleTopY })
|
||||||
|
.min(by: { lhs, rhs in
|
||||||
|
abs(lhs.1.minY - visibleTopY) < abs(rhs.1.minY - visibleTopY)
|
||||||
|
}),
|
||||||
|
let slotId = currentSections[safe: target.0.section]?.id,
|
||||||
|
slotId != viewModel.selectedTimeSlotId else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModel.syncVisibleTimeSlot(id: slotId)
|
||||||
|
sidebarView.apply(
|
||||||
|
groups: viewModel.sidebarGroups,
|
||||||
|
selectedSlotId: viewModel.selectedTimeSlotId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func rebuildStats() {
|
private func rebuildStats() {
|
||||||
statsCard.arrangedSubviews.forEach { view in
|
statsCard.arrangedSubviews.forEach { view in
|
||||||
statsCard.removeArrangedSubview(view)
|
statsCard.removeArrangedSubview(view)
|
||||||
@ -518,6 +618,8 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||||
statsCard.addArrangedSubview(button)
|
statsCard.addArrangedSubview(button)
|
||||||
}
|
}
|
||||||
|
statsCard.bringSubviewToFront(firstStatsDivider)
|
||||||
|
statsCard.bringSubviewToFront(secondStatsDivider)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureChipButton(_ button: UIButton) {
|
private func configureChipButton(_ button: UIButton) {
|
||||||
@ -527,18 +629,34 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
button.layer.cornerRadius = 8
|
button.layer.cornerRadius = 8
|
||||||
button.layer.borderWidth = 1
|
button.layer.borderWidth = 1
|
||||||
button.layer.borderColor = AppColor.border.cgColor
|
button.layer.borderColor = AppColor.border.cgColor
|
||||||
|
button.contentHorizontalAlignment = .leading
|
||||||
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
|
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton {
|
private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton {
|
||||||
var config = UIButton.Configuration.plain()
|
var config = UIButton.Configuration.plain()
|
||||||
config.title = "\(count)\n\(title)"
|
config.title = "\(count)"
|
||||||
|
config.subtitle = title
|
||||||
config.titleAlignment = .center
|
config.titleAlignment = .center
|
||||||
config.baseForegroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
|
config.titlePadding = 2
|
||||||
|
config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
|
||||||
|
let foregroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
|
||||||
|
config.baseForegroundColor = foregroundColor
|
||||||
config.background.backgroundColor = .white
|
config.background.backgroundColor = .white
|
||||||
|
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .systemFont(ofSize: 17, weight: selected ? .semibold : .medium)
|
||||||
|
outgoing.foregroundColor = foregroundColor
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
config.subtitleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .systemFont(ofSize: 12, weight: selected ? .semibold : .regular)
|
||||||
|
outgoing.foregroundColor = foregroundColor
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
let button = UIButton(configuration: config)
|
let button = UIButton(configuration: config)
|
||||||
button.titleLabel?.numberOfLines = 2
|
button.contentVerticalAlignment = .center
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular)
|
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -596,15 +714,6 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
private func confirmDeletePhoto(_ photoId: String) {
|
|
||||||
let alert = UIAlertController(title: "删除照片", message: "仅删除本地 OTG 缓存,不会删除服务端已上传素材。", preferredStyle: .alert)
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
|
||||||
self?.viewModel.deletePhoto(photoId: photoId)
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func backTapped() {
|
@objc private func backTapped() {
|
||||||
navigationController?.popViewController(animated: true)
|
navigationController?.popViewController(animated: true)
|
||||||
}
|
}
|
||||||
@ -621,9 +730,9 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
|
guard let importViewModel = viewModel.makeCameraImportViewModel() else { return }
|
||||||
viewModel.pauseLiveTransferForHistoricalImport()
|
viewModel.pauseLiveTransferForHistoricalImport()
|
||||||
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
|
let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel)
|
||||||
importController.onImportFinished = { [weak self] importedCount, failedCount in
|
importController.onImportFinished = { [weak self] importedCount, failedCount, importedPhotoIds in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.viewModel.reloadLocalPhotos()
|
self.viewModel.handleNewlyImportedPhotoIds(importedPhotoIds)
|
||||||
self.viewModel.start()
|
self.viewModel.start()
|
||||||
if failedCount > 0 {
|
if failedCount > 0 {
|
||||||
self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败")
|
self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败")
|
||||||
@ -639,8 +748,30 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
present(navigationController, animated: true)
|
present(navigationController, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func albumImportTapped() {
|
||||||
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||||
|
configuration.filter = .images
|
||||||
|
configuration.selectionLimit = 0
|
||||||
|
let picker = PHPickerViewController(configuration: configuration)
|
||||||
|
picker.delegate = self
|
||||||
|
present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func specifyTapped() {
|
@objc private func specifyTapped() {
|
||||||
viewModel.onSpecifyUploadButtonClick()
|
guard viewModel.canOpenSpecifyUploadChooser else { return }
|
||||||
|
let controller = WiredSpecifyUploadViewController(options: viewModel.specifyUploadOptions)
|
||||||
|
controller.onConfirm = { [weak self] option in
|
||||||
|
self?.viewModel.onSpecifyUploadOptionSelected(option)
|
||||||
|
}
|
||||||
|
controller.modalPresentationStyle = .pageSheet
|
||||||
|
if let sheet = controller.sheetPresentationController {
|
||||||
|
sheet.detents = [
|
||||||
|
.custom(identifier: .init("wiredSpecifyUpload")) { _ in 260 }
|
||||||
|
]
|
||||||
|
sheet.prefersGrabberVisible = false
|
||||||
|
sheet.preferredCornerRadius = 16
|
||||||
|
}
|
||||||
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func selectAllTapped() {
|
@objc private func selectAllTapped() {
|
||||||
@ -660,15 +791,293 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return }
|
guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return }
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
|
||||||
|
guard let url = item.thumbnailURL else {
|
||||||
|
showToast("暂无可预览图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let previewItem: MediaPreviewItem = url.isFileURL
|
||||||
|
? .localImage(url: url)
|
||||||
|
: MediaPreviewItem(source: .remoteImage(url))
|
||||||
|
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
guard !results.isEmpty else { return }
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
showLoading()
|
||||||
|
let items = await loadPhoneAlbumImportItems(from: results)
|
||||||
|
let result = viewModel.importPhoneAlbumImages(items)
|
||||||
|
hideLoading()
|
||||||
|
if result.failedCount > 0 {
|
||||||
|
showToast("已导入 \(result.importedCount) 张,\(result.failedCount) 张失败")
|
||||||
|
} else if result.importedCount > 0 {
|
||||||
|
showToast("已导入 \(result.importedCount) 张照片")
|
||||||
|
} else {
|
||||||
|
showToast("未能导入照片")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPhoneAlbumImportItems(from results: [PHPickerResult]) async -> [TravelAlbumPhoneAlbumImportItem] {
|
||||||
|
var items: [TravelAlbumPhoneAlbumImportItem] = []
|
||||||
|
for (index, result) in results.enumerated() {
|
||||||
|
if let item = await Self.loadPhoneAlbumImportItem(from: result, index: index) {
|
||||||
|
items.append(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadPhoneAlbumImportItem(
|
||||||
|
from result: PHPickerResult,
|
||||||
|
index: Int
|
||||||
|
) async -> TravelAlbumPhoneAlbumImportItem? {
|
||||||
|
let provider = result.itemProvider
|
||||||
|
let typeIdentifier = preferredImageTypeIdentifier(from: provider)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in
|
||||||
|
guard let data, !data.isEmpty else {
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(
|
||||||
|
returning: TravelAlbumPhoneAlbumImportItem(
|
||||||
|
data: data,
|
||||||
|
fileName: importFileName(provider: provider, typeIdentifier: typeIdentifier, index: index),
|
||||||
|
createdAt: Date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String {
|
||||||
|
provider.registeredTypeIdentifiers.first { identifier in
|
||||||
|
UTType(identifier)?.conforms(to: .image) == true
|
||||||
|
} ?? UTType.image.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func importFileName(provider: NSItemProvider, typeIdentifier: String, index: Int) -> String {
|
||||||
|
let fallback = "album_image_\(Int(Date().timeIntervalSince1970))_\(index)"
|
||||||
|
let rawName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let baseName = rawName?.isEmpty == false ? rawName! : fallback
|
||||||
|
guard URL(fileURLWithPath: baseName).pathExtension.isEmpty else {
|
||||||
|
return baseName
|
||||||
|
}
|
||||||
|
let ext = UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg"
|
||||||
|
return "\(baseName).\(ext)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OTG 指定上传选项弹窗。
|
||||||
|
private final class WiredSpecifyUploadViewController: UIViewController {
|
||||||
|
var onConfirm: ((TravelAlbumOTGSpecifyUploadOption) -> Void)?
|
||||||
|
|
||||||
|
private let options: [TravelAlbumOTGSpecifyUploadOption]
|
||||||
|
private var selectedOption: TravelAlbumOTGSpecifyUploadOption
|
||||||
|
private var optionRows: [WiredSpecifyUploadOptionRow] = []
|
||||||
|
private let headerView = UIView()
|
||||||
|
private let cancelTitleButton = UIButton(type: .system)
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let optionsStack = UIStackView()
|
||||||
|
private let bottomButtonsStack = UIStackView()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
init(options: [TravelAlbumOTGSpecifyUploadOption]) {
|
||||||
|
let resolvedOptions = options.isEmpty ? TravelAlbumOTGSpecifyUploadOption.allCases : options
|
||||||
|
self.options = resolvedOptions
|
||||||
|
selectedOption = resolvedOptions[0]
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
|
||||||
|
cancelTitleButton.setTitle("取消", for: .normal)
|
||||||
|
cancelTitleButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
cancelTitleButton.titleLabel?.font = .systemFont(ofSize: 16)
|
||||||
|
cancelTitleButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
titleLabel.text = "指定上传"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
optionsStack.axis = .vertical
|
||||||
|
optionsStack.spacing = 12
|
||||||
|
for option in options {
|
||||||
|
let row = WiredSpecifyUploadOptionRow(option: option)
|
||||||
|
row.apply(selected: option == selectedOption)
|
||||||
|
row.onTap = { [weak self] option in self?.selectOption(option) }
|
||||||
|
optionsStack.addArrangedSubview(row)
|
||||||
|
row.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(52)
|
||||||
|
}
|
||||||
|
optionRows.append(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomButtonsStack.axis = .horizontal
|
||||||
|
bottomButtonsStack.spacing = 12
|
||||||
|
bottomButtonsStack.distribution = .fillEqually
|
||||||
|
configureActionButton(cancelButton, title: "取消", filled: false)
|
||||||
|
configureActionButton(confirmButton, title: "确认上传", filled: true)
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
view.addSubview(headerView)
|
||||||
|
headerView.addSubview(cancelTitleButton)
|
||||||
|
headerView.addSubview(titleLabel)
|
||||||
|
view.addSubview(optionsStack)
|
||||||
|
view.addSubview(bottomButtonsStack)
|
||||||
|
bottomButtonsStack.addArrangedSubview(cancelButton)
|
||||||
|
bottomButtonsStack.addArrangedSubview(confirmButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
headerView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(52)
|
||||||
|
}
|
||||||
|
cancelTitleButton.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.height.equalTo(40)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.greaterThanOrEqualTo(cancelTitleButton.snp.trailing).offset(12)
|
||||||
|
}
|
||||||
|
optionsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(headerView.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
bottomButtonsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(optionsStack.snp.bottom).offset(20)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureActionButton(_ button: UIButton, title: String, filled: Bool) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
button.layer.cornerRadius = 10
|
||||||
|
if filled {
|
||||||
|
button.backgroundColor = AppColor.primary
|
||||||
|
button.setTitleColor(.white, for: .normal)
|
||||||
|
} else {
|
||||||
|
button.backgroundColor = .white
|
||||||
|
button.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
button.layer.borderWidth = 1
|
||||||
|
button.layer.borderColor = AppColor.primary.cgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectOption(_ option: TravelAlbumOTGSpecifyUploadOption) {
|
||||||
|
selectedOption = option
|
||||||
|
optionRows.forEach { row in
|
||||||
|
row.apply(selected: row.option == option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
let option = selectedOption
|
||||||
|
dismiss(animated: true) { [onConfirm] in
|
||||||
|
onConfirm?(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OTG 指定上传单选行。
|
||||||
|
private final class WiredSpecifyUploadOptionRow: UIControl {
|
||||||
|
let option: TravelAlbumOTGSpecifyUploadOption
|
||||||
|
var onTap: ((TravelAlbumOTGSpecifyUploadOption) -> Void)?
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let radioView = UIImageView()
|
||||||
|
|
||||||
|
init(option: TravelAlbumOTGSpecifyUploadOption) {
|
||||||
|
self.option = option
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(selected: Bool) {
|
||||||
|
radioView.image = UIImage(systemName: selected ? "largecircle.fill.circle" : "circle")
|
||||||
|
radioView.tintColor = selected ? AppColor.textPrimary : AppColor.border
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
layer.borderWidth = 1
|
||||||
|
layer.borderColor = AppColor.border.cgColor
|
||||||
|
|
||||||
|
titleLabel.text = option.title
|
||||||
|
titleLabel.font = .systemFont(ofSize: 15)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
radioView.contentMode = .scaleAspectFit
|
||||||
|
addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(radioView)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(14)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.trailing.lessThanOrEqualTo(radioView.snp.leading).offset(-10)
|
||||||
|
}
|
||||||
|
radioView.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().offset(-14)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func rowTapped() {
|
||||||
|
onTap?(option)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WiredCameraTransferViewController: UICollectionViewDelegate {
|
extension WiredCameraTransferViewController: UICollectionViewDelegate {
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
guard scrollView === collectionView else { return }
|
||||||
|
syncSidebarSelectionWithCurrentScrollPosition()
|
||||||
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
if viewModel.selectUploadMode {
|
if viewModel.selectUploadMode {
|
||||||
viewModel.toggleTransferPhotoSelection(photoId: item.id)
|
viewModel.toggleTransferPhotoSelection(photoId: item.id)
|
||||||
} else if item.status == .failed {
|
} else {
|
||||||
viewModel.retryPhoto(photoId: item.id)
|
presentPhotoPreview(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -783,9 +1192,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
|||||||
statusLabel.textAlignment = .center
|
statusLabel.textAlignment = .center
|
||||||
statusLabel.layer.cornerRadius = 3
|
statusLabel.layer.cornerRadius = 3
|
||||||
statusLabel.clipsToBounds = true
|
statusLabel.clipsToBounds = true
|
||||||
|
statusLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
sizeLabel.font = .systemFont(ofSize: 10)
|
sizeLabel.font = .systemFont(ofSize: 10)
|
||||||
sizeLabel.textColor = AppColor.textTertiary
|
sizeLabel.textColor = AppColor.textTertiary
|
||||||
progressView.progressTintColor = AppColor.primary
|
progressView.progressTintColor = AppColor.primary
|
||||||
@ -909,6 +1322,7 @@ private final class WiredTransferTimeSidebarView: UIView {
|
|||||||
private let headerButton = UIButton(type: .system)
|
private let headerButton = UIButton(type: .system)
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let stackView = UIStackView()
|
private let stackView = UIStackView()
|
||||||
|
private weak var selectedRowView: TimeSlotRowView?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@ -921,6 +1335,7 @@ private final class WiredTransferTimeSidebarView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) {
|
func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) {
|
||||||
|
selectedRowView = nil
|
||||||
stackView.arrangedSubviews.forEach { view in
|
stackView.arrangedSubviews.forEach { view in
|
||||||
stackView.removeArrangedSubview(view)
|
stackView.removeArrangedSubview(view)
|
||||||
view.removeFromSuperview()
|
view.removeFromSuperview()
|
||||||
@ -945,7 +1360,11 @@ private final class WiredTransferTimeSidebarView: UIView {
|
|||||||
|
|
||||||
for slot in group.slots {
|
for slot in group.slots {
|
||||||
let row = TimeSlotRowView()
|
let row = TimeSlotRowView()
|
||||||
row.apply(slot: slot, selected: slot.id == selectedSlotId)
|
let selected = slot.id == selectedSlotId
|
||||||
|
row.apply(slot: slot, selected: selected)
|
||||||
|
if selected {
|
||||||
|
selectedRowView = row
|
||||||
|
}
|
||||||
row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) }
|
row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) }
|
||||||
stackView.addArrangedSubview(row)
|
stackView.addArrangedSubview(row)
|
||||||
row.snp.makeConstraints { make in
|
row.snp.makeConstraints { make in
|
||||||
@ -953,6 +1372,7 @@ private final class WiredTransferTimeSidebarView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scrollSelectedRowIntoVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
@ -988,6 +1408,13 @@ private final class WiredTransferTimeSidebarView: UIView {
|
|||||||
@objc private func toggleTapped() {
|
@objc private func toggleTapped() {
|
||||||
onToggleSidebar?()
|
onToggleSidebar?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scrollSelectedRowIntoVisible() {
|
||||||
|
guard let selectedRowView else { return }
|
||||||
|
layoutIfNeeded()
|
||||||
|
let rowFrame = selectedRowView.convert(selectedRowView.bounds, to: stackView)
|
||||||
|
scrollView.scrollRectToVisible(rowFrame.insetBy(dx: 0, dy: -8), animated: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class TimeSlotRowView: UIControl {
|
private final class TimeSlotRowView: UIControl {
|
||||||
|
|||||||
@ -133,7 +133,12 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class WiredCameraTransferViewModelTests: XCTestCase {
|
final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||||
func testDefaultDisconnectedStateAndOptions() {
|
func testDefaultDisconnectedStateAndOptions() {
|
||||||
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
|
albumId: 1,
|
||||||
|
albumTitle: "相册",
|
||||||
|
headerPhone: "13800138000",
|
||||||
|
userDefaults: makeOTGTestDefaults()
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
|
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
|
||||||
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
|
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
|
||||||
@ -142,6 +147,46 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
|
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTransferModePersistsLocallyWithoutAlbumScope() {
|
||||||
|
let defaults = makeOTGTestDefaults()
|
||||||
|
let firstContext = makeOTGTestContext()
|
||||||
|
let secondContext = makeOTGTestContext()
|
||||||
|
let firstViewModel = makeWiredViewModel(
|
||||||
|
albumId: 9,
|
||||||
|
context: firstContext,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
userDefaults: defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
firstViewModel.selectTransferMode("拍后传输")
|
||||||
|
|
||||||
|
let secondViewModel = makeWiredViewModel(
|
||||||
|
albumId: 18,
|
||||||
|
context: secondContext,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
userDefaults: defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConnectedStatusIncludesCameraModelName() {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(
|
||||||
|
state: .connected(platform: .canon, deviceName: "Canon R8"),
|
||||||
|
driver: MockCameraDriver(objects: [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.start()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.cameraStatusText, "Canon R8已连接")
|
||||||
|
XCTAssertFalse(viewModel.deviceModelName.isEmpty)
|
||||||
|
XCTAssertTrue(viewModel.availableStorageText.hasSuffix("GB 可用"))
|
||||||
|
}
|
||||||
|
|
||||||
func testTransferPhotoSectionsAndSidebarUseDescendingHalfHourSlots() {
|
func testTransferPhotoSectionsAndSidebarUseDescendingHalfHourSlots() {
|
||||||
let photos = [
|
let photos = [
|
||||||
makeOTGPhotoItem(id: "a", capturedAt: "2026-07-08 12:05:00"),
|
makeOTGPhotoItem(id: "a", capturedAt: "2026-07-08 12:05:00"),
|
||||||
@ -183,6 +228,34 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
|
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSyncVisibleTimeSlotDoesNotNotifyStateChange() throws {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
context.store.save([
|
||||||
|
try makePersistedOTGRecord(context: context, id: "first", capturedAt: "2026-07-08 12:35:00", status: .pending),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "second", capturedAt: "2026-07-08 12:05:00", status: .pending),
|
||||||
|
], albumId: 9)
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.reloadLocalPhotos()
|
||||||
|
var stateChangeCount = 0
|
||||||
|
viewModel.onStateChange = {
|
||||||
|
stateChangeCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.syncVisibleTimeSlot(id: "2026-07-08T12:00")
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:00")
|
||||||
|
XCTAssertEqual(stateChangeCount, 0)
|
||||||
|
|
||||||
|
viewModel.selectTimeSlot(id: "2026-07-08T12:30")
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
|
||||||
|
XCTAssertEqual(stateChangeCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func testSelectionModeAllowsPendingAndFailedOnly() throws {
|
func testSelectionModeAllowsPendingAndFailedOnly() throws {
|
||||||
let context = makeOTGTestContext()
|
let context = makeOTGTestContext()
|
||||||
context.store.save([
|
context.store.save([
|
||||||
@ -280,6 +353,137 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
|
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPostTransferModeKeepsNewImportsPending() throws {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let uploader = MockTravelAlbumOTGUploader()
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
uploader: uploader
|
||||||
|
)
|
||||||
|
let records = [
|
||||||
|
try makePersistedOTGRecord(context: context, id: "a", capturedAt: "2026-07-08 12:05:00", status: .pending),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "b", capturedAt: "2026-07-08 12:06:00", status: .pending),
|
||||||
|
]
|
||||||
|
context.store.save(records, albumId: 9)
|
||||||
|
|
||||||
|
viewModel.selectTransferMode("拍后传输")
|
||||||
|
viewModel.handleNewlyImportedPhotoIds(records.map(\.id))
|
||||||
|
|
||||||
|
XCTAssertEqual(uploader.uploadCallCount, 0)
|
||||||
|
XCTAssertEqual(Set(context.store.load(albumId: 9).map(\.status)), [.pending])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpecifyUploadTodayCapturedOnlyUploadsTodayNotUploadedPhotos() async throws {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let uploader = MockTravelAlbumOTGUploader()
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
uploader: uploader
|
||||||
|
)
|
||||||
|
let today = TravelAlbumOTGPhotoStore.transferTimeText(Date())
|
||||||
|
let yesterday = TravelAlbumOTGPhotoStore.transferTimeText(Date().addingTimeInterval(-86_400))
|
||||||
|
let records = [
|
||||||
|
try makePersistedOTGRecord(context: context, id: "today-pending", capturedAt: today, status: .pending),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "today-failed", capturedAt: today, status: .failed),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "today-uploaded", capturedAt: today, status: .uploaded),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "yesterday-pending", capturedAt: yesterday, status: .pending),
|
||||||
|
]
|
||||||
|
context.store.save(records, albumId: 9)
|
||||||
|
|
||||||
|
viewModel.reloadLocalPhotos()
|
||||||
|
viewModel.onSpecifyUploadOptionSelected(.todayCaptured)
|
||||||
|
await waitUntil { uploader.uploadCallCount == 2 }
|
||||||
|
|
||||||
|
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["today-failed", "today-pending"])
|
||||||
|
let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
|
||||||
|
XCTAssertEqual(recordsById["today-pending"]?.status, .uploaded)
|
||||||
|
XCTAssertEqual(recordsById["today-failed"]?.status, .uploaded)
|
||||||
|
XCTAssertEqual(recordsById["today-uploaded"]?.status, .uploaded)
|
||||||
|
XCTAssertEqual(recordsById["yesterday-pending"]?.status, .pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpecifyUploadAllPendingUploadsPendingAndFailedPhotos() async throws {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let uploader = MockTravelAlbumOTGUploader()
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
uploader: uploader
|
||||||
|
)
|
||||||
|
let records = [
|
||||||
|
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:06:00", status: .failed),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:07:00", status: .uploaded),
|
||||||
|
]
|
||||||
|
context.store.save(records, albumId: 9)
|
||||||
|
|
||||||
|
viewModel.reloadLocalPhotos()
|
||||||
|
viewModel.onSpecifyUploadOptionSelected(.allPending)
|
||||||
|
await waitUntil { uploader.uploadCallCount == 2 }
|
||||||
|
|
||||||
|
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["failed", "pending"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSwitchingToLiveUploadOnlyUploadsNewImports() async throws {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let uploader = MockTravelAlbumOTGUploader()
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
uploader: uploader
|
||||||
|
)
|
||||||
|
let oldRecords = [
|
||||||
|
try makePersistedOTGRecord(context: context, id: "old-a", capturedAt: "2026-07-08 12:05:00", status: .pending),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "old-b", capturedAt: "2026-07-08 12:06:00", status: .pending),
|
||||||
|
]
|
||||||
|
context.store.save(oldRecords, albumId: 9)
|
||||||
|
|
||||||
|
viewModel.selectTransferMode("拍后传输")
|
||||||
|
viewModel.handleNewlyImportedPhotoIds(oldRecords.map(\.id))
|
||||||
|
|
||||||
|
let newRecords = [
|
||||||
|
try makePersistedOTGRecord(context: context, id: "new-c", capturedAt: "2026-07-08 12:10:00", status: .pending),
|
||||||
|
try makePersistedOTGRecord(context: context, id: "new-d", capturedAt: "2026-07-08 12:11:00", status: .pending),
|
||||||
|
]
|
||||||
|
context.store.save(oldRecords + newRecords, albumId: 9)
|
||||||
|
|
||||||
|
viewModel.selectTransferMode("边拍边传")
|
||||||
|
viewModel.handleNewlyImportedPhotoIds(newRecords.map(\.id))
|
||||||
|
await waitUntil { uploader.uploadCallCount == 2 }
|
||||||
|
|
||||||
|
let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
|
||||||
|
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["new-c", "new-d"])
|
||||||
|
XCTAssertEqual(recordsById["old-a"]?.status, .pending)
|
||||||
|
XCTAssertEqual(recordsById["old-b"]?.status, .pending)
|
||||||
|
XCTAssertEqual(recordsById["new-c"]?.status, .uploaded)
|
||||||
|
XCTAssertEqual(recordsById["new-d"]?.status, .uploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPhoneAlbumImportWritesLocalRecordsAndAutoUploadsInLiveMode() async {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let uploader = MockTravelAlbumOTGUploader()
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
uploader: uploader
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = viewModel.importPhoneAlbumImages([
|
||||||
|
TravelAlbumPhoneAlbumImportItem(data: Data([1, 2, 3]), fileName: "album.jpg"),
|
||||||
|
TravelAlbumPhoneAlbumImportItem(data: Data([4, 5, 6]), fileName: "album.jpg"),
|
||||||
|
])
|
||||||
|
await waitUntil { uploader.uploadCallCount == 2 }
|
||||||
|
|
||||||
|
let records = context.store.load(albumId: 9)
|
||||||
|
XCTAssertEqual(result.importedCount, 2)
|
||||||
|
XCTAssertEqual(result.failedCount, 0)
|
||||||
|
XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id)))
|
||||||
|
XCTAssertEqual(Set(records.map(\.status)), [.uploaded])
|
||||||
|
XCTAssertEqual(Set(records.map(\.fileName)).count, 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册相机历史导入 ViewModel 测试。
|
/// 旅拍相册相机历史导入 ViewModel 测试。
|
||||||
@ -395,6 +599,11 @@ final class TravelAlbumCameraImportViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(records.count, 1)
|
XCTAssertEqual(records.count, 1)
|
||||||
XCTAssertEqual(records.first?.sourceId, selected.id)
|
XCTAssertEqual(records.first?.sourceId, selected.id)
|
||||||
XCTAssertEqual(records.first?.status, .pending)
|
XCTAssertEqual(records.first?.status, .pending)
|
||||||
|
if case .finished(_, _, let importedPhotoIds) = viewModel.state {
|
||||||
|
XCTAssertEqual(importedPhotoIds, records.map(\.id))
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected finished state")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testImportSelectedContinuesAfterSingleFailure() async {
|
func testImportSelectedContinuesAfterSingleFailure() async {
|
||||||
@ -425,9 +634,10 @@ final class TravelAlbumCameraImportViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id])
|
XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id])
|
||||||
XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id))
|
XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id))
|
||||||
XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id)
|
XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id)
|
||||||
if case .finished(let importedCount, let failedCount) = viewModel.state {
|
if case .finished(let importedCount, let failedCount, let importedPhotoIds) = viewModel.state {
|
||||||
XCTAssertEqual(importedCount, 1)
|
XCTAssertEqual(importedCount, 1)
|
||||||
XCTAssertEqual(failedCount, 1)
|
XCTAssertEqual(failedCount, 1)
|
||||||
|
XCTAssertEqual(importedPhotoIds, context.store.load(albumId: 9).map(\.id))
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Expected finished state")
|
XCTFail("Expected finished state")
|
||||||
}
|
}
|
||||||
@ -469,18 +679,21 @@ private struct OTGTestContext {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeWiredViewModel(
|
private func makeWiredViewModel(
|
||||||
|
albumId: Int = 9,
|
||||||
context: OTGTestContext,
|
context: OTGTestContext,
|
||||||
manager: MockWiredCameraConnectionManager,
|
manager: MockWiredCameraConnectionManager,
|
||||||
uploader: MockTravelAlbumOTGUploader? = nil
|
uploader: MockTravelAlbumOTGUploader? = nil,
|
||||||
|
userDefaults: UserDefaults = makeOTGTestDefaults()
|
||||||
) -> WiredCameraTransferViewModel {
|
) -> WiredCameraTransferViewModel {
|
||||||
prepareAppStoreForOTGTests()
|
prepareAppStoreForOTGTests()
|
||||||
return WiredCameraTransferViewModel(
|
return WiredCameraTransferViewModel(
|
||||||
albumId: 9,
|
albumId: albumId,
|
||||||
albumTitle: "相册",
|
albumTitle: "相册",
|
||||||
headerPhone: "13800138000",
|
headerPhone: "13800138000",
|
||||||
connectionManager: manager,
|
connectionManager: manager,
|
||||||
storage: context.store,
|
storage: context.store,
|
||||||
uploader: uploader ?? MockTravelAlbumOTGUploader()
|
uploader: uploader ?? MockTravelAlbumOTGUploader(),
|
||||||
|
userDefaults: userDefaults
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,6 +773,13 @@ private func makeOTGTestContext() -> OTGTestContext {
|
|||||||
return OTGTestContext(root: root, store: store)
|
return OTGTestContext(root: root, store: store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeOTGTestDefaults() -> UserDefaults {
|
||||||
|
let suiteName = "TravelAlbumOTGTests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func prepareAppStoreForOTGTests() {
|
private func prepareAppStoreForOTGTests() {
|
||||||
AppStore.shared.userId = "u1"
|
AppStore.shared.userId = "u1"
|
||||||
@ -664,6 +884,7 @@ private final class MockCameraDriver: CameraDriver {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||||
private(set) var uploadCallCount = 0
|
private(set) var uploadCallCount = 0
|
||||||
|
private(set) var uploadedRecordIds: [String] = []
|
||||||
|
|
||||||
func upload(
|
func upload(
|
||||||
record: TravelAlbumOTGPhotoRecord,
|
record: TravelAlbumOTGPhotoRecord,
|
||||||
@ -671,6 +892,7 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
|||||||
progress: @escaping (Int) -> Void
|
progress: @escaping (Int) -> Void
|
||||||
) async throws -> TravelAlbumMaterial {
|
) async throws -> TravelAlbumMaterial {
|
||||||
uploadCallCount += 1
|
uploadCallCount += 1
|
||||||
|
uploadedRecordIds.append(record.id)
|
||||||
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
|
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user