feat: add travel album OTG import flow
This commit is contained in:
@ -5,32 +5,435 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 有线相机传输页 ViewModel,仅承载断开空态 UI 文案,不接入 OTG 或图片存储业务。
|
||||
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
|
||||
@MainActor
|
||||
final class WiredCameraTransferViewModel {
|
||||
let albumId: Int
|
||||
let albumTitle: String
|
||||
let headerPhone: String
|
||||
let scenicSpotLabel: String?
|
||||
let cameraStatusText = "未连接 USB"
|
||||
let actionButtonText = "等待连接"
|
||||
let deviceModelName = "设备存储"
|
||||
let availableStorageText = "-- GB 可用"
|
||||
|
||||
private(set) var connectionState: ConnectionState = .idle {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var selectedPhotoIds: Set<String> = [] {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var selectUploadMode = false {
|
||||
didSet { notifyStateChanged() }
|
||||
}
|
||||
private(set) var sonyMTPHint: String?
|
||||
private(set) var isContentCatalogReady = false
|
||||
|
||||
let retouchOption = "不修图"
|
||||
let photoFormatOption = "JPG"
|
||||
let transferModeOption = "边拍边传"
|
||||
let tabCounts = [0, 0, 0]
|
||||
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
|
||||
private(set) var transferModeOption = "边拍边传"
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onLiveShotSaved: ((String) -> Void)?
|
||||
|
||||
init(albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil) {
|
||||
private let connectionManager: any WiredCameraConnectionManaging
|
||||
private let storage: TravelAlbumOTGPhotoStore
|
||||
private let repository: TravelAlbumOTGPhotoRepository
|
||||
private let uploader: any TravelAlbumOTGUploading
|
||||
private let appStore: AppStore
|
||||
|
||||
private var currentDriver: CameraDriver?
|
||||
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
|
||||
private var deletedPhotoIds: Set<String> = []
|
||||
private var isUploading = false
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
albumId: Int,
|
||||
albumTitle: String,
|
||||
headerPhone: String,
|
||||
scenicSpotLabel: String? = nil,
|
||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||
appStore: AppStore = .shared
|
||||
) {
|
||||
self.albumId = albumId
|
||||
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
|
||||
self.headerPhone = headerPhone
|
||||
self.scenicSpotLabel = scenicSpotLabel
|
||||
self.connectionManager = connectionManager ?? ConnectionManager.shared
|
||||
self.storage = storage
|
||||
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
|
||||
self.uploader = uploader ?? TravelAlbumOTGUploader()
|
||||
self.appStore = appStore
|
||||
}
|
||||
|
||||
/// 响应所有传输按钮点击,仅提示 UI 已同步。
|
||||
func showUIOnlyMessage() {
|
||||
onShowMessage?("仅同步 UI,暂未接入 OTG 传输")
|
||||
/// 相机状态文案。
|
||||
var cameraStatusText: String {
|
||||
switch connectionState {
|
||||
case .idle, .disconnected:
|
||||
return "未连接 USB"
|
||||
case .searching:
|
||||
return "正在搜索"
|
||||
case .connecting:
|
||||
return "正在连接"
|
||||
case .connected:
|
||||
return "已连接"
|
||||
case .failed:
|
||||
return "连接失败"
|
||||
}
|
||||
}
|
||||
|
||||
/// 操作按钮文案。
|
||||
var actionButtonText: String {
|
||||
switch connectionState {
|
||||
case .idle, .disconnected, .failed:
|
||||
return "重新连接"
|
||||
case .searching, .connecting:
|
||||
return "等待连接"
|
||||
case .connected:
|
||||
return "刷新"
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备名称。
|
||||
var deviceModelName: String {
|
||||
switch connectionState {
|
||||
case .connecting(let name), .connected(_, let name):
|
||||
return name
|
||||
default:
|
||||
return "设备存储"
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前设备容量占位文案。
|
||||
var availableStorageText: String {
|
||||
"-- GB 可用"
|
||||
}
|
||||
|
||||
/// Tab 统计。
|
||||
var tabCounts: [Int] {
|
||||
[
|
||||
photos.count,
|
||||
photos.filter { $0.status == .uploaded }.count,
|
||||
photos.filter { $0.status == .failed }.count,
|
||||
]
|
||||
}
|
||||
|
||||
/// 过滤后的照片列表。
|
||||
func filteredPhotos() -> [TravelAlbumOTGPhotoItem] {
|
||||
switch selectedTab {
|
||||
case .all:
|
||||
return photos
|
||||
case .uploaded:
|
||||
return photos.filter { $0.status == .uploaded }
|
||||
case .failed:
|
||||
return photos.filter { $0.status == .failed }
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定连接管理器并启动发现。
|
||||
func start() {
|
||||
connectionManager.configureLiveTransfer(albumID: albumId)
|
||||
connectionManager.delegate = self
|
||||
syncFromConnectionManager()
|
||||
loadPersistedPhotos()
|
||||
connectionManager.start()
|
||||
}
|
||||
|
||||
/// 页面消失时解绑 UI delegate,保留底层设备缓存。
|
||||
func stop() {
|
||||
connectionManager.unbindDelegate()
|
||||
}
|
||||
|
||||
/// 主动断开相机连接。
|
||||
func disconnect() {
|
||||
connectionManager.disconnect()
|
||||
}
|
||||
|
||||
/// 刷新相机文件列表或重新连接。
|
||||
func refreshCameraFiles() {
|
||||
switch connectionState {
|
||||
case .connected:
|
||||
loadPersistedPhotos()
|
||||
case .idle, .disconnected, .failed:
|
||||
connectionManager.start()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载当前相册本地 OTG 照片。
|
||||
func reloadLocalPhotos() {
|
||||
loadPersistedPhotos()
|
||||
}
|
||||
|
||||
/// 切换 Tab。
|
||||
func selectTab(_ tab: TravelAlbumOTGTransferTab) {
|
||||
selectedTab = tab
|
||||
selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id)))
|
||||
}
|
||||
|
||||
/// 切换传输模式。
|
||||
func selectTransferMode(_ option: String) {
|
||||
guard option == "边拍边传" || option == "拍后传输" else { return }
|
||||
transferModeOption = option
|
||||
}
|
||||
|
||||
/// 切换上传格式。
|
||||
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
|
||||
guard photoFormatOption != option else { return }
|
||||
photoFormatOption = option
|
||||
applyMergedPhotos()
|
||||
}
|
||||
|
||||
/// 批量上传按钮:首次进入选择模式,再次上传选中照片。
|
||||
func onBatchUploadButtonClick() {
|
||||
if !selectUploadMode {
|
||||
selectUploadMode = true
|
||||
selectedPhotoIds = []
|
||||
return
|
||||
}
|
||||
if selectedPhotoIds.isEmpty {
|
||||
selectUploadMode = false
|
||||
return
|
||||
}
|
||||
uploadSelectedTransferPhotos()
|
||||
}
|
||||
|
||||
/// 指定上传:默认上传所有未上传照片。
|
||||
func onSpecifyUploadButtonClick() {
|
||||
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
|
||||
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
|
||||
}
|
||||
|
||||
/// 创建相机历史照片选择导入页 ViewModel。
|
||||
func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? {
|
||||
guard isContentCatalogReady else {
|
||||
showMessage("相机照片目录尚未就绪,请稍候再试")
|
||||
return nil
|
||||
}
|
||||
guard let currentDriver else {
|
||||
showMessage("请先连接相机")
|
||||
return nil
|
||||
}
|
||||
if case .connected = connectionState {
|
||||
return TravelAlbumCameraImportViewModel(
|
||||
albumId: albumId,
|
||||
albumTitle: albumTitle,
|
||||
driver: currentDriver,
|
||||
storage: storage,
|
||||
appStore: appStore
|
||||
)
|
||||
}
|
||||
showMessage("请先连接相机")
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 选择或取消选择照片。
|
||||
func toggleTransferPhotoSelection(photoId: String) {
|
||||
guard selectUploadMode,
|
||||
photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else {
|
||||
return
|
||||
}
|
||||
if selectedPhotoIds.contains(photoId) {
|
||||
selectedPhotoIds.remove(photoId)
|
||||
} else {
|
||||
selectedPhotoIds.insert(photoId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传选中照片。
|
||||
func uploadSelectedTransferPhotos() {
|
||||
let ids = selectedPhotoIds
|
||||
guard !ids.isEmpty else {
|
||||
showMessage("请先选择照片")
|
||||
return
|
||||
}
|
||||
selectUploadMode = false
|
||||
selectedPhotoIds = []
|
||||
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
|
||||
}
|
||||
|
||||
/// 重试上传单张照片。
|
||||
func retryPhoto(photoId: String) {
|
||||
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
|
||||
}
|
||||
|
||||
/// 删除单张本地照片。
|
||||
func deletePhoto(photoId: String) {
|
||||
storage.remove(albumId: albumId, photoId: photoId)
|
||||
deletedPhotoIds.insert(photoId)
|
||||
persistedRecordsById.removeValue(forKey: photoId)
|
||||
selectedPhotoIds.remove(photoId)
|
||||
applyMergedPhotos()
|
||||
showMessage("已删除")
|
||||
}
|
||||
|
||||
/// 清空当前相册本地 OTG 缓存。
|
||||
func clearLocalAlbumCache() {
|
||||
storage.clearAlbum(albumId: albumId)
|
||||
persistedRecordsById = [:]
|
||||
photos = []
|
||||
}
|
||||
|
||||
private func syncFromConnectionManager() {
|
||||
currentDriver = connectionManager.currentDriver
|
||||
isContentCatalogReady = connectionManager.isContentCatalogReady
|
||||
connectionState = connectionManager.state
|
||||
}
|
||||
|
||||
private func loadPersistedPhotos() {
|
||||
deletedPhotoIds = storage.loadDeletedIds(albumId: albumId)
|
||||
let records = storage.load(albumId: albumId)
|
||||
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
|
||||
applyMergedPhotos()
|
||||
}
|
||||
|
||||
private func applyMergedPhotos() {
|
||||
let persistedItems = persistedRecordsById.values
|
||||
.filter { !deletedPhotoIds.contains($0.id) }
|
||||
.map { $0.toPhotoItem() }
|
||||
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
|
||||
}
|
||||
|
||||
private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
|
||||
guard !isUploading else {
|
||||
showMessage("正在上传,请稍候")
|
||||
return
|
||||
}
|
||||
let targets = resolveUploadTargets(ids)
|
||||
guard !targets.isEmpty else {
|
||||
showMessage(emptyMessage)
|
||||
return
|
||||
}
|
||||
isUploading = true
|
||||
showMessage("开始上传 \(targets.count) 张照片")
|
||||
Task {
|
||||
for id in targets {
|
||||
await uploadPhoto(id: id)
|
||||
}
|
||||
isUploading = false
|
||||
notifyStateChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
|
||||
ids.filter { persistedRecordsById[$0] != nil }
|
||||
}
|
||||
|
||||
private func uploadPhoto(id: String) async {
|
||||
do {
|
||||
let record = try await localRecordForUpload(id: id)
|
||||
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
|
||||
let material = try await uploader.upload(
|
||||
record: record,
|
||||
scenicId: appStore.currentScenicId
|
||||
) { [weak self] progress in
|
||||
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
|
||||
}
|
||||
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
|
||||
} catch {
|
||||
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
|
||||
showMessage(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
|
||||
if let record = persistedRecordsById[id],
|
||||
!record.localPath.isEmpty,
|
||||
FileManager.default.fileExists(atPath: record.localPath) {
|
||||
return record
|
||||
}
|
||||
throw TravelAlbumOTGUploadError.localFileMissing
|
||||
}
|
||||
|
||||
private func updateRecord(
|
||||
id: String,
|
||||
status: TravelAlbumOTGUploadStatus,
|
||||
progress: Int,
|
||||
error: String?,
|
||||
remoteUrl: String? = nil
|
||||
) {
|
||||
var record = persistedRecordsById[id]
|
||||
if record == nil, let item = photos.first(where: { $0.id == id }) {
|
||||
record = TravelAlbumOTGPhotoRecord(
|
||||
id: item.id,
|
||||
sourceId: item.sourceId,
|
||||
fileName: item.fileName,
|
||||
localPath: item.localPath,
|
||||
thumbnailPath: item.thumbnailURL?.isFileURL == true ? item.thumbnailURL?.path ?? "" : "",
|
||||
capturedAt: item.capturedAt,
|
||||
fileSizeBytes: item.fileSizeBytes,
|
||||
status: status,
|
||||
progress: progress,
|
||||
errorMessage: error,
|
||||
albumId: albumId,
|
||||
userId: appStore.userId,
|
||||
remoteUrl: remoteUrl ?? item.remoteUrl
|
||||
)
|
||||
}
|
||||
guard var record else { return }
|
||||
record.status = status
|
||||
record.progress = min(max(progress, 0), 100)
|
||||
record.errorMessage = error
|
||||
if let remoteUrl {
|
||||
record.remoteUrl = remoteUrl
|
||||
}
|
||||
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
persistedRecordsById[id] = record
|
||||
storage.upsert(record, albumId: albumId)
|
||||
applyMergedPhotos()
|
||||
}
|
||||
|
||||
private func notifyStateChanged() {
|
||||
if Thread.isMainThread {
|
||||
onStateChange?()
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in self?.onStateChange?() }
|
||||
}
|
||||
}
|
||||
|
||||
private func showMessage(_ message: String) {
|
||||
if Thread.isMainThread {
|
||||
onShowMessage?(message)
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
|
||||
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
|
||||
connectionState = state
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
|
||||
currentDriver = driver
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
|
||||
currentDriver = driver
|
||||
isContentCatalogReady = true
|
||||
notifyStateChanged()
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
|
||||
showMessage(error.localizedDescription)
|
||||
}
|
||||
|
||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
|
||||
showMessage("长时间未找到相机,请尝试重新插拔连接线")
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {
|
||||
guard albumID == albumId else { return }
|
||||
loadPersistedPhotos()
|
||||
onLiveShotSaved?(filename)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user