From 98dc810455d87da37ce8e77799a62cf3fabe68cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 10 Jul 2026 11:02:00 +0800 Subject: [PATCH] Update OTG transfer workflow --- .../Canon/CanonRemoteCaptureService.swift | 21 +- .../NikonCatalogLiveCaptureService.swift | 16 +- .../Sony/SonyRemoteCaptureService.swift | 8 +- .../OTG/Core/ConnectionManager.swift | 14 +- .../Models/TravelAlbumOTGTransferModels.swift | 38 ++ .../Storage/TravelAlbumOTGPhotoStore.swift | 11 +- .../TravelAlbumCameraImportViewModel.swift | 10 +- .../WiredCameraTransferViewModel.swift | 285 +++++++++- ...ravelAlbumCameraImportViewController.swift | 6 +- .../WiredCameraTransferViewController.swift | 527 ++++++++++++++++-- .../TravelAlbumViewModelTests.swift | 232 +++++++- 11 files changed, 1047 insertions(+), 121 deletions(-) diff --git a/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonRemoteCaptureService.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonRemoteCaptureService.swift index 868dad5..fa1dad0 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonRemoteCaptureService.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonRemoteCaptureService.swift @@ -15,8 +15,8 @@ final class CanonRemoteCaptureService { private var albumID: Int? private let photoRepository: PhotoRepositoryProtocol - /// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。 - var onShotSaved: ((String) -> Void)? + /// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。 + var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)? private var eventPollTimer: Timer? private var catalogPollTimer: Timer? @@ -175,14 +175,14 @@ final class CanonRemoteCaptureService { 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) return } savedShotCount += 1 OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)") - onShotSaved?(filename) + onShotSaved?(record) } catch { knownCatalogIDs.remove(objectID) OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)") @@ -417,23 +417,23 @@ final class CanonRemoteCaptureService { lastDownloadedSignature = signature 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 } savedShotCount += 1 OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(result.filename), bytes=\(result.data.count)") - onShotSaved?(result.filename) + onShotSaved?(record) return true } 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 { OTGLog.warning(.canon, "\(reason): live transfer album not configured, skip save") - return false + return nil } do { @@ -442,15 +442,14 @@ final class CanonRemoteCaptureService { filename: filename, albumID: albumID ) - try photoRepository.addPhoto( + return try photoRepository.addPhoto( albumID: albumID, localPath: fileURL.path, createdAt: Date() ) - return true } catch { OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)") - return false + return nil } } } diff --git a/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift index a8aeff5..9da910d 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift @@ -15,7 +15,8 @@ final class NikonCatalogLiveCaptureService { private var albumID: Int? private let photoRepository: PhotoRepositoryProtocol - var onShotSaved: ((String) -> Void)? + /// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。 + var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)? private var catalogPollTimer: Timer? private var isDownloading = false @@ -140,14 +141,14 @@ final class NikonCatalogLiveCaptureService { 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) return } savedShotCount += 1 OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)") - onShotSaved?(filename) + onShotSaved?(record) } catch { knownCatalogIDs.remove(objectID) OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)") @@ -184,10 +185,10 @@ final class NikonCatalogLiveCaptureService { // 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 { OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save") - return false + return nil } do { @@ -196,15 +197,14 @@ final class NikonCatalogLiveCaptureService { filename: filename, albumID: albumID ) - try photoRepository.addPhoto( + return try photoRepository.addPhoto( albumID: albumID, localPath: fileURL.path, createdAt: Date() ) - return true } catch { OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)") - return false + return nil } } } diff --git a/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyRemoteCaptureService.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyRemoteCaptureService.swift index 0ddee09..220a6c3 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyRemoteCaptureService.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyRemoteCaptureService.swift @@ -15,8 +15,8 @@ final class SonyRemoteCaptureService { private var albumID: Int? private let photoRepository: PhotoRepositoryProtocol - /// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。 - var onShotSaved: ((String) -> Void)? + /// 下载成功时回调本地照片记录,供 ConnectionManager 转发给 ViewModel。 + var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)? private var pollTimer: Timer? private var propertyCheckTask: Task? @@ -298,7 +298,7 @@ final class SonyRemoteCaptureService { filename: downloaded.filename, albumID: albumID ) - try photoRepository.addPhoto( + let record = try photoRepository.addPhoto( albumID: albumID, localPath: fileURL.path, createdAt: Date() @@ -307,7 +307,7 @@ final class SonyRemoteCaptureService { .sony, "Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count), path=\(fileURL.lastPathComponent)" ) - onShotSaved?(downloaded.filename) + onShotSaved?(record) return true } catch { OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)") diff --git a/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift index f55e8d0..b9e42ee 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift @@ -15,14 +15,14 @@ protocol ConnectionManagerDelegate: AnyObject { func connectionManager(_ manager: ConnectionManager, didFail error: Error) func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) - /// 边拍边传:单张照片已下载并写入目标相册。 - func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) + /// 边拍边传:单张照片已下载并写入目标相册本地缓存。 + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) } extension ConnectionManagerDelegate { func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {} func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {} - func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {} + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {} } /// 有线相机连接管理抽象,便于页面 ViewModel 测试连接与目录就绪状态。 @@ -553,14 +553,14 @@ private extension ConnectionManager { nikonLiveCapture = nil } - private func makeLiveShotSavedHandler() -> (String) -> Void { - { [weak self] filename in + private func makeLiveShotSavedHandler() -> (TravelAlbumOTGPhotoRecord) -> Void { + { [weak self] record in guard let self, let albumID = self.liveTransferAlbumID else { return } + guard record.albumId == albumID else { return } self.notify { self.delegate?.connectionManager( self, - didSaveLiveShot: filename, - albumID: albumID + didSaveLiveShot: record ) } } diff --git a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift index 731bc78..de1aa1d 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift @@ -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 { /// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。 func capturedDate() -> Date? { diff --git a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift index e60b3fe..d971b2d 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift @@ -89,7 +89,8 @@ protocol PhotoRepositoryProtocol { 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 @@ -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 attributes = try? FileManager.default.attributesOfItem(atPath: localPath) let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 let userId = storage.context.userId - guard !userId.isEmpty else { return } + guard !userId.isEmpty else { + throw CameraError.connectionFailed("用户信息缺失,无法保存照片") + } let record = TravelAlbumOTGPhotoRecord( id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt), @@ -128,6 +132,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol { userId: userId ) storage.upsert(record, albumId: albumID) + return record } /// 删除指定照片及本地文件。 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift index 0d78182..5f00371 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift @@ -18,7 +18,7 @@ enum TravelAlbumCameraImportState: Equatable { case loading case loaded(sections: [TravelAlbumCameraImportSection]) case importing(current: Int, total: Int) - case finished(importedCount: Int, failedCount: Int) + case finished(importedCount: Int, failedCount: Int, importedPhotoIds: [String]) case failed(message: String) } @@ -180,6 +180,7 @@ final class TravelAlbumCameraImportViewModel { Task { let directory = try? storage.originalsDirectory(albumId: albumId) var importedCount = 0 + var importedPhotoIds: [String] = [] for (index, object) in selected.enumerated() { defer { state = .importing(current: index + 1, total: selected.count) } @@ -204,6 +205,7 @@ final class TravelAlbumCameraImportViewModel { ) storage.upsert(record, albumId: albumId) existingPhotoIds.insert(object.id) + importedPhotoIds.append(record.id) importedCount += 1 } catch { failedPhotoIds.insert(object.id) @@ -212,7 +214,11 @@ final class TravelAlbumCameraImportViewModel { } selectedPhotoIds.subtract(existingPhotoIds) - state = .finished(importedCount: importedCount, failedCount: failedPhotoIds.count) + state = .finished( + importedCount: importedCount, + failedCount: failedPhotoIds.count, + importedPhotoIds: importedPhotoIds + ) } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 96ceca5..d5b04af 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -3,11 +3,28 @@ // suixinkan // +import Darwin 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 连接、照片导入、本地缓存与上传登记。 @MainActor final class WiredCameraTransferViewModel { + private static let transferModeDefaultsKey = "travelAlbum.otg.transferMode" + let albumId: Int let albumTitle: String let headerPhone: String @@ -38,14 +55,19 @@ final class WiredCameraTransferViewModel { didSet { notifyStateChanged() } } private(set) var selectedTimeSlotId: String? { - didSet { notifyStateChanged() } + didSet { + guard !suppressSelectedTimeSlotNotification else { return } + notifyStateChanged() + } } private(set) var sonyMTPHint: String? private(set) var isContentCatalogReady = false let retouchOption = "不修图" private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg - private(set) var transferModeOption = "边拍边传" + private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { + didSet { notifyStateChanged() } + } var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? @@ -56,10 +78,13 @@ final class WiredCameraTransferViewModel { private let repository: TravelAlbumOTGPhotoRepository private let uploader: any TravelAlbumOTGUploading private let appStore: AppStore + private let userDefaults: UserDefaults private var currentDriver: CameraDriver? private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] private var isUploading = false + private var queuedAutoUploadPhotoIds: Set = [] + private var suppressSelectedTimeSlotNotification = false @MainActor init( @@ -70,7 +95,8 @@ final class WiredCameraTransferViewModel { connectionManager: (any WiredCameraConnectionManaging)? = nil, storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), uploader: (any TravelAlbumOTGUploading)? = nil, - appStore: AppStore = .shared + appStore: AppStore = .shared, + userDefaults: UserDefaults = .standard ) { self.albumId = albumId self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle @@ -81,6 +107,8 @@ final class WiredCameraTransferViewModel { self.repository = TravelAlbumOTGPhotoRepository(storage: storage) self.uploader = uploader ?? TravelAlbumOTGUploader() self.appStore = appStore + self.userDefaults = userDefaults + self.transferMode = Self.persistedTransferMode(in: userDefaults) } /// 相机状态文案。 @@ -92,8 +120,8 @@ final class WiredCameraTransferViewModel { return "正在搜索" case .connecting: return "正在连接" - case .connected: - return "已连接" + case .connected(_, let name): + return "\(name)已连接" case .failed: return "连接失败" } @@ -111,19 +139,22 @@ final class WiredCameraTransferViewModel { } } - /// 设备名称。 + /// 当前手机型号名称。 var deviceModelName: String { - switch connectionState { - case .connecting(let name), .connected(_, let name): - return name - default: - return "设备存储" - } + Self.currentPhoneModelName() } - /// 当前设备容量占位文案。 + /// 当前手机可用存储空间。 var availableStorageText: String { - "-- GB 可用" + Self.availableStorageText() + } + + /// 是否已连接相机。 + var isCameraConnected: Bool { + if case .connected = connectionState { + return true + } + return false } /// Tab 统计。 @@ -173,6 +204,21 @@ final class WiredCameraTransferViewModel { selectedPhotoIds.intersection(selectableVisiblePhotoIds).count } + /// 传输模式展示文案。 + var transferModeOption: String { + transferMode.title + } + + /// 指定上传弹窗选项。 + var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { + TravelAlbumOTGSpecifyUploadOption.allCases + } + + /// 是否可打开指定上传选项。 + var canOpenSpecifyUploadChooser: Bool { + !selectUploadMode + } + /// 绑定连接管理器并启动发现。 func start() { connectionManager.configureLiveTransfer(albumID: albumId) @@ -227,10 +273,25 @@ final class WiredCameraTransferViewModel { 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) { - guard option == "边拍边传" || option == "拍后传输" else { return } - transferModeOption = option + guard let mode = TravelAlbumOTGTransferMode.option(title: option), + mode != transferMode else { + return + } + transferMode = mode + userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) } /// 切换上传格式。 @@ -254,10 +315,15 @@ final class WiredCameraTransferViewModel { uploadSelectedTransferPhotos() } - /// 指定上传:默认上传所有未上传照片。 - func onSpecifyUploadButtonClick() { - let ids = Set(photos.filter(\.isNotUploaded).map(\.id)) - startUploadByIds(ids, emptyMessage: "暂无未上传的照片") + /// 选择指定上传选项后开始上传。 + func onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) { + guard canOpenSpecifyUploadChooser else { return } + switch option { + case .allPending: + uploadAllNotUploadedPhotos() + case .todayCaptured: + uploadAllTodayCapturedPhotos() + } } /// 创建相机历史照片选择导入页 ViewModel。 @@ -288,6 +354,49 @@ final class WiredCameraTransferViewModel { 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) { guard selectUploadMode, @@ -325,6 +434,22 @@ final class WiredCameraTransferViewModel { 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) { startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传") @@ -375,9 +500,17 @@ final class WiredCameraTransferViewModel { } } - private func startUploadByIds(_ ids: Set, emptyMessage: String) { + private func startUploadByIds( + _ ids: Set, + emptyMessage: String, + queueIfBusy: Bool = false + ) { guard !isUploading else { - showMessage("正在上传,请稍候") + if queueIfBusy { + queuedAutoUploadPhotoIds.formUnion(ids) + } else { + showMessage("正在上传,请稍候") + } return } let targets = resolveUploadTargets(ids) @@ -386,18 +519,32 @@ final class WiredCameraTransferViewModel { return } isUploading = true - showMessage("开始上传 \(targets.count) 张照片") Task { for id in targets { await uploadPhoto(id: id) } isUploading = false 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] { - 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 { @@ -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) { if Thread.isMainThread { onShowMessage?(message) @@ -507,9 +736,9 @@ extension WiredCameraTransferViewModel: ConnectionManagerDelegate { showMessage("长时间未找到相机,请尝试重新插拔连接线") } - func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) { - guard albumID == albumId else { return } - loadPersistedPhotos() - onLiveShotSaved?(filename) + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) { + guard record.albumId == albumId else { return } + handleNewlyImportedPhotoIds([record.id]) + onLiveShotSaved?(record.fileName) } } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift index c91358a..7225c5e 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift @@ -16,7 +16,7 @@ final class TravelAlbumCameraImportViewController: UIViewController { } private let viewModel: TravelAlbumCameraImportViewModel - var onImportFinished: ((Int, Int) -> Void)? + var onImportFinished: ((Int, Int, [String]) -> Void)? var onImportCancelled: (() -> Void)? private var collectionView: UICollectionView! @@ -193,11 +193,11 @@ final class TravelAlbumCameraImportViewController: UIViewController { navigationItem.leftBarButtonItem?.isEnabled = false activityIndicator.startAnimating() - case .finished(let importedCount, let failedCount): + case .finished(let importedCount, let failedCount, let importedPhotoIds): isModalInPresentation = false activityIndicator.stopAnimating() dismiss(animated: true) { [weak self] in - self?.onImportFinished?(importedCount, failedCount) + self?.onImportFinished?(importedCount, failedCount, importedPhotoIds) } case .failed(let message): diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index 1a66d23..a472f87 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -4,8 +4,10 @@ // import Kingfisher +import PhotosUI import SnapKit import UIKit +import UniformTypeIdentifiers private enum WiredTransferMetrics { static let sidebarWidth: CGFloat = 76 @@ -43,7 +45,11 @@ final class WiredCameraTransferViewController: BaseViewController { private let retouchButton = UIButton(type: .system) private let formatButton = UIButton(type: .system) private let modeButton = UIButton(type: .system) + private let modeChevronView = UIImageView() + private let settingsStatsDivider = UIView() private let statsCard = UIStackView() + private let firstStatsDivider = UIView() + private let secondStatsDivider = UIView() private let photoPanelView = UIView() private let selectAllBar = UIControl() private let selectAllIconView = UIImageView() @@ -61,9 +67,12 @@ final class WiredCameraTransferViewController: BaseViewController { private var pendingScrollSlotId: String? private var selectAllBarHeightConstraint: Constraint? private let bottomBar = UIView() + private let bottomButtonsStack = UIStackView() private let batchButton = UIButton(type: .system) private let historyImportButton = UIButton(type: .system) + private let albumImportButton = UIButton(type: .system) private let specifyButton = UIButton(type: .system) + private var isHistoryImportButtonVisible = false init(viewModel: WiredCameraTransferViewModel) { self.viewModel = viewModel @@ -121,9 +130,13 @@ final class WiredCameraTransferViewController: BaseViewController { storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium) storageTitleLabel.textColor = AppColor.textPrimary storageTitleLabel.textAlignment = .center + storageTitleLabel.adjustsFontSizeToFitWidth = true + storageTitleLabel.minimumScaleFactor = 0.72 storageValueLabel.font = .systemFont(ofSize: 10) storageValueLabel.textColor = AppColor.primary storageValueLabel.textAlignment = .center + storageValueLabel.adjustsFontSizeToFitWidth = true + storageValueLabel.minimumScaleFactor = 0.72 statusLabel.font = .systemFont(ofSize: 11, weight: .medium) statusLabel.layer.cornerRadius = 4 @@ -139,13 +152,25 @@ final class WiredCameraTransferViewController: BaseViewController { chipsStack.axis = .horizontal chipsStack.spacing = 6 + chipsStack.distribution = .fillEqually [retouchButton, formatButton, modeButton].forEach { configureChipButton($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.distribution = .fillEqually + firstStatsDivider.backgroundColor = AppColor.border + secondStatsDivider.backgroundColor = AppColor.border photoPanelView.backgroundColor = .white selectAllBar.backgroundColor = .white @@ -200,9 +225,15 @@ final class WiredCameraTransferViewController: BaseViewController { emptyLabel.textAlignment = .center bottomBar.backgroundColor = .white + bottomButtonsStack.axis = .horizontal + bottomButtonsStack.spacing = 8 + bottomButtonsStack.distribution = .fillEqually configureBottomButton(batchButton, title: "批量上传", filled: true) configureBottomButton(historyImportButton, title: "历史导入", filled: false) + configureBottomButton(albumImportButton, title: "相册导入", filled: false) configureBottomButton(specifyButton, title: "指定上传", filled: false) + historyImportButton.isHidden = true + historyImportButton.alpha = 0 view.addSubview(headerView) headerView.addSubview(backButton) @@ -216,7 +247,10 @@ final class WiredCameraTransferViewController: BaseViewController { statusCard.addSubview(refreshButton) statusCard.addSubview(helpLabel) statusCard.addSubview(chipsStack) + statusCard.addSubview(settingsStatsDivider) statusCard.addSubview(statsCard) + statsCard.addSubview(firstStatsDivider) + statsCard.addSubview(secondStatsDivider) view.addSubview(photoPanelView) photoPanelView.addSubview(selectAllBar) selectAllBar.addSubview(selectAllIconView) @@ -230,9 +264,12 @@ final class WiredCameraTransferViewController: BaseViewController { listContainerView.addSubview(expandSidebarButton) view.addSubview(emptyLabel) view.addSubview(bottomBar) - bottomBar.addSubview(batchButton) - bottomBar.addSubview(historyImportButton) - bottomBar.addSubview(specifyButton) + bottomBar.addSubview(bottomButtonsStack) + modeButton.addSubview(modeChevronView) + bottomButtonsStack.addArrangedSubview(batchButton) + bottomButtonsStack.addArrangedSubview(specifyButton) + bottomButtonsStack.addArrangedSubview(albumImportButton) + bottomButtonsStack.addArrangedSubview(historyImportButton) } override func setupConstraints() { @@ -259,8 +296,9 @@ final class WiredCameraTransferViewController: BaseViewController { make.leading.trailing.equalToSuperview().inset(16) } storagePanel.snp.makeConstraints { make in - make.top.leading.equalToSuperview().offset(14) - make.width.greaterThanOrEqualTo(76) + make.top.equalToSuperview().offset(14) + make.leading.equalToSuperview().offset(14) + make.width.equalTo(96) make.height.equalTo(76) } storageTitleLabel.snp.makeConstraints { make in @@ -273,7 +311,8 @@ final class WiredCameraTransferViewController: BaseViewController { } statusLabel.snp.makeConstraints { make in 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.width.greaterThanOrEqualTo(84) } @@ -281,41 +320,52 @@ final class WiredCameraTransferViewController: BaseViewController { make.centerY.equalTo(statusLabel) make.trailing.equalToSuperview().offset(-14) make.height.equalTo(34) - make.width.greaterThanOrEqualTo(72) + make.width.greaterThanOrEqualTo(64) } helpLabel.snp.makeConstraints { make in make.top.equalTo(statusLabel.snp.bottom).offset(4) make.leading.equalTo(statusLabel) - make.trailing.equalTo(refreshButton) + make.trailing.equalToSuperview().offset(-14) } chipsStack.snp.makeConstraints { make in make.top.equalTo(storagePanel.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(14) 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 - make.top.equalTo(chipsStack.snp.bottom).offset(14) + make.top.equalTo(settingsStatsDivider.snp.bottom).offset(2) make.leading.trailing.bottom.equalToSuperview() 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 make.leading.trailing.bottom.equalToSuperview() } - batchButton.snp.makeConstraints { make in + bottomButtonsStack.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) - make.leading.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(44) 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 make.top.equalTo(statusCard.snp.bottom).offset(14) make.leading.trailing.equalToSuperview() @@ -366,13 +416,11 @@ final class WiredCameraTransferViewController: BaseViewController { viewModel.onShowMessage = { [weak self] message in 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) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), 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) selectAllBar.addTarget(self, action: #selector(selectAllTapped), for: .touchUpInside) expandSidebarButton.addTarget(self, action: #selector(toggleSidebarTapped), for: .touchUpInside) @@ -381,12 +429,8 @@ final class WiredCameraTransferViewController: BaseViewController { self?.pendingScrollSlotId = slotId self?.viewModel.selectTimeSlot(id: slotId) } - formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in - UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) } - }) - formatButton.showsMenuAsPrimaryAction = true - modeButton.menu = UIMenu(children: ["边拍边传", "拍后传输"].map { option in - UIAction(title: option) { [weak self] _ in self?.viewModel.selectTransferMode(option) } + modeButton.menu = UIMenu(children: TravelAlbumOTGTransferMode.allCases.map { mode in + UIAction(title: mode.title) { [weak self] _ in self?.viewModel.selectTransferMode(mode.title) } }) modeButton.showsMenuAsPrimaryAction = true helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped))) @@ -403,7 +447,7 @@ final class WiredCameraTransferViewController: BaseViewController { refreshButton.setTitle(viewModel.actionButtonText, 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) helpLabel.attributedText = helpText(viewModel.sonyMTPHint) @@ -435,6 +479,37 @@ final class WiredCameraTransferViewController: BaseViewController { batchButton.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() { @@ -488,7 +563,7 @@ final class WiredCameraTransferViewController: BaseViewController { let selected = viewModel.selectedPhotoIds.contains(item.id) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) 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() { @@ -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() { statsCard.arrangedSubviews.forEach { view in statsCard.removeArrangedSubview(view) @@ -518,6 +618,8 @@ final class WiredCameraTransferViewController: BaseViewController { button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) statsCard.addArrangedSubview(button) } + statsCard.bringSubviewToFront(firstStatsDivider) + statsCard.bringSubviewToFront(secondStatsDivider) } private func configureChipButton(_ button: UIButton) { @@ -527,18 +629,34 @@ final class WiredCameraTransferViewController: BaseViewController { button.layer.cornerRadius = 8 button.layer.borderWidth = 1 button.layer.borderColor = AppColor.border.cgColor + button.contentHorizontalAlignment = .leading button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) } private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton { var config = UIButton.Configuration.plain() - config.title = "\(count)\n\(title)" + config.title = "\(count)" + config.subtitle = title 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.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) - button.titleLabel?.numberOfLines = 2 - button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular) + button.contentVerticalAlignment = .center return button } @@ -596,15 +714,6 @@ final class WiredCameraTransferViewController: BaseViewController { 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() { navigationController?.popViewController(animated: true) } @@ -621,9 +730,9 @@ final class WiredCameraTransferViewController: BaseViewController { guard let importViewModel = viewModel.makeCameraImportViewModel() else { return } viewModel.pauseLiveTransferForHistoricalImport() 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 } - self.viewModel.reloadLocalPhotos() + self.viewModel.handleNewlyImportedPhotoIds(importedPhotoIds) self.viewModel.start() if failedCount > 0 { self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败") @@ -639,8 +748,30 @@ final class WiredCameraTransferViewController: BaseViewController { 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() { - 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() { @@ -660,15 +791,293 @@ final class WiredCameraTransferViewController: BaseViewController { guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return } 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 { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === collectionView else { return } + syncSidebarSelectionWithCurrentScrollPosition() + } + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } if viewModel.selectUploadMode { viewModel.toggleTransferPhotoSelection(photoId: item.id) - } else if item.status == .failed { - viewModel.retryPhoto(photoId: item.id) + } else { + presentPhotoPreview(item) } } } @@ -783,9 +1192,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell { statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 3 statusLabel.clipsToBounds = true + statusLabel.setContentHuggingPriority(.required, for: .horizontal) + statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal) titleLabel.font = .systemFont(ofSize: 12, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.lineBreakMode = .byTruncatingMiddle + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.textColor = AppColor.textTertiary progressView.progressTintColor = AppColor.primary @@ -909,6 +1322,7 @@ private final class WiredTransferTimeSidebarView: UIView { private let headerButton = UIButton(type: .system) private let scrollView = UIScrollView() private let stackView = UIStackView() + private weak var selectedRowView: TimeSlotRowView? override init(frame: CGRect) { super.init(frame: frame) @@ -921,6 +1335,7 @@ private final class WiredTransferTimeSidebarView: UIView { } func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) { + selectedRowView = nil stackView.arrangedSubviews.forEach { view in stackView.removeArrangedSubview(view) view.removeFromSuperview() @@ -945,7 +1360,11 @@ private final class WiredTransferTimeSidebarView: UIView { for slot in group.slots { 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) } stackView.addArrangedSubview(row) row.snp.makeConstraints { make in @@ -953,6 +1372,7 @@ private final class WiredTransferTimeSidebarView: UIView { } } } + scrollSelectedRowIntoVisible() } private func setupUI() { @@ -988,6 +1408,13 @@ private final class WiredTransferTimeSidebarView: UIView { @objc private func toggleTapped() { 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 { diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index ac67bc7..fb8b12d 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -133,7 +133,12 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { @MainActor final class WiredCameraTransferViewModelTests: XCTestCase { 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.tabCounts, [0, 0, 0]) @@ -142,6 +147,46 @@ final class WiredCameraTransferViewModelTests: XCTestCase { 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() { let photos = [ 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") } + 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 { let context = makeOTGTestContext() context.store.save([ @@ -280,6 +353,137 @@ final class WiredCameraTransferViewModelTests: XCTestCase { 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 测试。 @@ -395,6 +599,11 @@ final class TravelAlbumCameraImportViewModelTests: XCTestCase { XCTAssertEqual(records.count, 1) XCTAssertEqual(records.first?.sourceId, selected.id) 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 { @@ -425,9 +634,10 @@ final class TravelAlbumCameraImportViewModelTests: XCTestCase { XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id]) XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.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(failedCount, 1) + XCTAssertEqual(importedPhotoIds, context.store.load(albumId: 9).map(\.id)) } else { XCTFail("Expected finished state") } @@ -469,18 +679,21 @@ private struct OTGTestContext { @MainActor private func makeWiredViewModel( + albumId: Int = 9, context: OTGTestContext, manager: MockWiredCameraConnectionManager, - uploader: MockTravelAlbumOTGUploader? = nil + uploader: MockTravelAlbumOTGUploader? = nil, + userDefaults: UserDefaults = makeOTGTestDefaults() ) -> WiredCameraTransferViewModel { prepareAppStoreForOTGTests() return WiredCameraTransferViewModel( - albumId: 9, + albumId: albumId, albumTitle: "相册", headerPhone: "13800138000", connectionManager: manager, 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) } +private func makeOTGTestDefaults() -> UserDefaults { + let suiteName = "TravelAlbumOTGTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults +} + @MainActor private func prepareAppStoreForOTGTests() { AppStore.shared.userId = "u1" @@ -664,6 +884,7 @@ private final class MockCameraDriver: CameraDriver { @MainActor private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { private(set) var uploadCallCount = 0 + private(set) var uploadedRecordIds: [String] = [] func upload( record: TravelAlbumOTGPhotoRecord, @@ -671,6 +892,7 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { progress: @escaping (Int) -> Void ) async throws -> TravelAlbumMaterial { uploadCallCount += 1 + uploadedRecordIds.append(record.id) return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)") } }