Update OTG transfer workflow

This commit is contained in:
2026-07-10 11:02:00 +08:00
parent 9d446b4bc3
commit 98dc810455
11 changed files with 1047 additions and 121 deletions

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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<Void, Never>?
@ -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)")

View File

@ -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
)
}
}

View File

@ -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? {

View File

@ -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
}
///

View File

@ -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
)
}
}

View File

@ -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<String> = []
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<String>, emptyMessage: String) {
private func startUploadByIds(
_ ids: Set<String>,
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>) -> [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)
}
}

View File

@ -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):

View File

@ -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 {