feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -0,0 +1,256 @@
//
// TravelAlbumCameraImportViewModel.swift
// suixinkan
//
import Foundation
///
struct TravelAlbumCameraImportSection: Equatable {
let dayKey: String
let title: String
let photos: [CameraObject]
}
///
enum TravelAlbumCameraImportState: Equatable {
case idle
case loading
case loaded(sections: [TravelAlbumCameraImportSection])
case importing(current: Int, total: Int)
case finished(importedCount: Int)
case failed(message: String)
}
/// ViewModel
@MainActor
final class TravelAlbumCameraImportViewModel {
let albumId: Int
let albumTitle: String
private(set) var state: TravelAlbumCameraImportState = .idle {
didSet {
onStateUpdated?(state)
onSonyMTPHelpVisibilityChanged?(shouldShowSonyMTPHelp)
}
}
private(set) var selectedPhotoIds: Set<String> = [] {
didSet { onSelectionUpdated?(selectedPhotoIds.count) }
}
private(set) var failedPhotoIds: Set<String> = []
var onStateUpdated: ((TravelAlbumCameraImportState) -> Void)?
var onSelectionUpdated: ((Int) -> Void)?
var onSonyMTPHelpVisibilityChanged: ((Bool) -> Void)?
private let driver: CameraDriver
private let storage: TravelAlbumOTGPhotoStore
private let appStore: AppStore
private var allPhotos: [CameraObject] = []
private var existingPhotoIds: Set<String> = []
private var thumbnailCache: [String: Data] = [:]
/// ViewModel
init(
albumId: Int,
albumTitle: String,
driver: CameraDriver,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
appStore: AppStore = .shared
) {
self.albumId = albumId
self.albumTitle = albumTitle
self.driver = driver
self.storage = storage
self.appStore = appStore
}
/// MTP
var shouldShowSonyMTPHelp: Bool {
guard driver.platform == .sony else { return false }
switch state {
case .loaded(let sections):
return sections.isEmpty
case .failed:
return allPhotos.isEmpty
default:
return false
}
}
///
func loadPhotos() {
state = .loading
Task {
do {
reloadExistingPhotoIds()
let objects = try await driver.listObjects()
allPhotos = objects
let sections = Self.groupByDay(objects)
if sections.isEmpty {
state = .failed(message: "相机中没有可导入的照片")
} else {
state = .loaded(sections: sections)
}
} catch {
state = .failed(message: error.localizedDescription)
}
}
}
///
func isPhotoAlreadyInAlbum(id: String) -> Bool {
existingPhotoIds.contains(id)
}
///
func togglePhoto(id: String) {
guard !isPhotoAlreadyInAlbum(id: id) else { return }
if selectedPhotoIds.contains(id) {
selectedPhotoIds.remove(id)
} else {
selectedPhotoIds.insert(id)
}
}
///
func toggleSection(_ section: TravelAlbumCameraImportSection) {
let ids = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id))
guard !ids.isEmpty else { return }
if ids.isSubset(of: selectedPhotoIds) {
selectedPhotoIds.subtract(ids)
} else {
selectedPhotoIds.formUnion(ids)
}
}
///
func isPhotoSelected(id: String) -> Bool {
selectedPhotoIds.contains(id)
}
///
func isSectionFullySelected(_ section: TravelAlbumCameraImportSection) -> Bool {
let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)
return !ids.isEmpty && ids.allSatisfy { selectedPhotoIds.contains($0) }
}
///
func isSectionPartiallySelected(_ section: TravelAlbumCameraImportSection) -> Bool {
let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)
let selectedCount = ids.filter { selectedPhotoIds.contains($0) }.count
return selectedCount > 0 && selectedCount < ids.count
}
///
func hasImportablePhotos(in section: TravelAlbumCameraImportSection) -> Bool {
section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) }
}
///
func importablePhotoCount(in section: TravelAlbumCameraImportSection) -> Int {
section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.count
}
///
func importedPhotoCount(in section: TravelAlbumCameraImportSection) -> Int {
section.photos.filter { isPhotoAlreadyInAlbum(id: $0.id) }.count
}
///
func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? {
if let cached = thumbnailCache[photo.id] {
return cached
}
guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else {
return nil
}
thumbnailCache[photo.id] = data
return data
}
///
func importSelected() {
let selected = allPhotos.filter { selectedPhotoIds.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id) }
guard !selected.isEmpty else { return }
state = .importing(current: 0, total: selected.count)
failedPhotoIds = []
Task {
let directory = try? storage.originalsDirectory(albumId: albumId)
var importedCount = 0
for (index, object) in selected.enumerated() {
defer { state = .importing(current: index + 1, total: selected.count) }
guard let directory else {
failedPhotoIds.insert(object.id)
continue
}
do {
let downloadedURL = try await driver.downloadObject(object, to: directory)
let record = TravelAlbumOTGPhotoRecord(
id: object.id,
sourceId: object.id,
fileName: downloadedURL.lastPathComponent,
localPath: downloadedURL.path,
thumbnailPath: "",
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
fileSizeBytes: object.fileSize,
status: .pending,
progress: 0,
albumId: albumId,
userId: appStore.userId
)
storage.upsert(record, albumId: albumId)
existingPhotoIds.insert(object.id)
importedCount += 1
} catch {
failedPhotoIds.insert(object.id)
OTGLog.error(.connection, "history import failed: \(object.filename) \(error.localizedDescription)")
}
}
selectedPhotoIds.subtract(existingPhotoIds)
state = .finished(importedCount: importedCount)
}
}
private func reloadExistingPhotoIds() {
existingPhotoIds = Set(
storage.load(albumId: albumId).flatMap { record in
[record.id, record.sourceId].filter { !$0.isEmpty }
}
)
}
private static func groupByDay(_ photos: [CameraObject]) -> [TravelAlbumCameraImportSection] {
let calendar = Calendar.current
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy年M月d日"
let grouped = Dictionary(grouping: photos) { photo in
calendar.startOfDay(for: photo.capturedAt)
}
return grouped.keys
.sorted(by: >)
.map { day in
TravelAlbumCameraImportSection(
dayKey: dayKeyString(for: day, calendar: calendar),
title: sectionTitle(for: day, formatter: formatter, calendar: calendar),
photos: grouped[day]!.sorted { $0.capturedAt > $1.capturedAt }
)
}
}
private static func dayKeyString(for day: Date, calendar: Calendar) -> String {
let components = calendar.dateComponents([.year, .month, .day], from: day)
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
}
private static func sectionTitle(for day: Date, formatter: DateFormatter, calendar: Calendar) -> String {
if calendar.isDateInToday(day) { return "今天" }
if calendar.isDateInYesterday(day) { return "昨天" }
return formatter.string(from: day)
}
}

View File

@ -5,32 +5,435 @@
import Foundation
/// 线 ViewModel UI OTG
/// 线 ViewModel ImageCaptureCore
@MainActor
final class WiredCameraTransferViewModel {
let albumId: Int
let albumTitle: String
let headerPhone: String
let scenicSpotLabel: String?
let cameraStatusText = "未连接 USB"
let actionButtonText = "等待连接"
let deviceModelName = "设备存储"
let availableStorageText = "-- GB 可用"
private(set) var connectionState: ConnectionState = .idle {
didSet { notifyStateChanged() }
}
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
didSet { notifyStateChanged() }
}
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
didSet { notifyStateChanged() }
}
private(set) var selectedPhotoIds: Set<String> = [] {
didSet { notifyStateChanged() }
}
private(set) var selectUploadMode = false {
didSet { notifyStateChanged() }
}
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图"
let photoFormatOption = "JPG"
let transferModeOption = "边拍边传"
let tabCounts = [0, 0, 0]
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferModeOption = "边拍边传"
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onLiveShotSaved: ((String) -> Void)?
init(albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil) {
private let connectionManager: any WiredCameraConnectionManaging
private let storage: TravelAlbumOTGPhotoStore
private let repository: TravelAlbumOTGPhotoRepository
private let uploader: any TravelAlbumOTGUploading
private let appStore: AppStore
private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
private var deletedPhotoIds: Set<String> = []
private var isUploading = false
@MainActor
init(
albumId: Int,
albumTitle: String,
headerPhone: String,
scenicSpotLabel: String? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
appStore: AppStore = .shared
) {
self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
self.headerPhone = headerPhone
self.scenicSpotLabel = scenicSpotLabel
self.connectionManager = connectionManager ?? ConnectionManager.shared
self.storage = storage
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
self.uploader = uploader ?? TravelAlbumOTGUploader()
self.appStore = appStore
}
/// UI
func showUIOnlyMessage() {
onShowMessage?("仅同步 UI暂未接入 OTG 传输")
///
var cameraStatusText: String {
switch connectionState {
case .idle, .disconnected:
return "未连接 USB"
case .searching:
return "正在搜索"
case .connecting:
return "正在连接"
case .connected:
return "已连接"
case .failed:
return "连接失败"
}
}
///
var actionButtonText: String {
switch connectionState {
case .idle, .disconnected, .failed:
return "重新连接"
case .searching, .connecting:
return "等待连接"
case .connected:
return "刷新"
}
}
///
var deviceModelName: String {
switch connectionState {
case .connecting(let name), .connected(_, let name):
return name
default:
return "设备存储"
}
}
///
var availableStorageText: String {
"-- GB 可用"
}
/// Tab
var tabCounts: [Int] {
[
photos.count,
photos.filter { $0.status == .uploaded }.count,
photos.filter { $0.status == .failed }.count,
]
}
///
func filteredPhotos() -> [TravelAlbumOTGPhotoItem] {
switch selectedTab {
case .all:
return photos
case .uploaded:
return photos.filter { $0.status == .uploaded }
case .failed:
return photos.filter { $0.status == .failed }
}
}
///
func start() {
connectionManager.configureLiveTransfer(albumID: albumId)
connectionManager.delegate = self
syncFromConnectionManager()
loadPersistedPhotos()
connectionManager.start()
}
/// UI delegate
func stop() {
connectionManager.unbindDelegate()
}
///
func disconnect() {
connectionManager.disconnect()
}
///
func refreshCameraFiles() {
switch connectionState {
case .connected:
loadPersistedPhotos()
case .idle, .disconnected, .failed:
connectionManager.start()
default:
break
}
}
/// OTG
func reloadLocalPhotos() {
loadPersistedPhotos()
}
/// Tab
func selectTab(_ tab: TravelAlbumOTGTransferTab) {
selectedTab = tab
selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id)))
}
///
func selectTransferMode(_ option: String) {
guard option == "边拍边传" || option == "拍后传输" else { return }
transferModeOption = option
}
///
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return }
photoFormatOption = option
applyMergedPhotos()
}
///
func onBatchUploadButtonClick() {
if !selectUploadMode {
selectUploadMode = true
selectedPhotoIds = []
return
}
if selectedPhotoIds.isEmpty {
selectUploadMode = false
return
}
uploadSelectedTransferPhotos()
}
///
func onSpecifyUploadButtonClick() {
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
}
/// ViewModel
func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? {
guard isContentCatalogReady else {
showMessage("相机照片目录尚未就绪,请稍候再试")
return nil
}
guard let currentDriver else {
showMessage("请先连接相机")
return nil
}
if case .connected = connectionState {
return TravelAlbumCameraImportViewModel(
albumId: albumId,
albumTitle: albumTitle,
driver: currentDriver,
storage: storage,
appStore: appStore
)
}
showMessage("请先连接相机")
return nil
}
///
func toggleTransferPhotoSelection(photoId: String) {
guard selectUploadMode,
photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else {
return
}
if selectedPhotoIds.contains(photoId) {
selectedPhotoIds.remove(photoId)
} else {
selectedPhotoIds.insert(photoId)
}
}
///
func uploadSelectedTransferPhotos() {
let ids = selectedPhotoIds
guard !ids.isEmpty else {
showMessage("请先选择照片")
return
}
selectUploadMode = false
selectedPhotoIds = []
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
}
///
func retryPhoto(photoId: String) {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
}
///
func deletePhoto(photoId: String) {
storage.remove(albumId: albumId, photoId: photoId)
deletedPhotoIds.insert(photoId)
persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId)
applyMergedPhotos()
showMessage("已删除")
}
/// OTG
func clearLocalAlbumCache() {
storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:]
photos = []
}
private func syncFromConnectionManager() {
currentDriver = connectionManager.currentDriver
isContentCatalogReady = connectionManager.isContentCatalogReady
connectionState = connectionManager.state
}
private func loadPersistedPhotos() {
deletedPhotoIds = storage.loadDeletedIds(albumId: albumId)
let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
applyMergedPhotos()
}
private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values
.filter { !deletedPhotoIds.contains($0.id) }
.map { $0.toPhotoItem() }
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
}
private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
guard !isUploading else {
showMessage("正在上传,请稍候")
return
}
let targets = resolveUploadTargets(ids)
guard !targets.isEmpty else {
showMessage(emptyMessage)
return
}
isUploading = true
showMessage("开始上传 \(targets.count) 张照片")
Task {
for id in targets {
await uploadPhoto(id: id)
}
isUploading = false
notifyStateChanged()
}
}
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
ids.filter { persistedRecordsById[$0] != nil }
}
private func uploadPhoto(id: String) async {
do {
let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
let material = try await uploader.upload(
record: record,
scenicId: appStore.currentScenicId
) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
}
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
} catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription)
}
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id],
!record.localPath.isEmpty,
FileManager.default.fileExists(atPath: record.localPath) {
return record
}
throw TravelAlbumOTGUploadError.localFileMissing
}
private func updateRecord(
id: String,
status: TravelAlbumOTGUploadStatus,
progress: Int,
error: String?,
remoteUrl: String? = nil
) {
var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) {
record = TravelAlbumOTGPhotoRecord(
id: item.id,
sourceId: item.sourceId,
fileName: item.fileName,
localPath: item.localPath,
thumbnailPath: item.thumbnailURL?.isFileURL == true ? item.thumbnailURL?.path ?? "" : "",
capturedAt: item.capturedAt,
fileSizeBytes: item.fileSizeBytes,
status: status,
progress: progress,
errorMessage: error,
albumId: albumId,
userId: appStore.userId,
remoteUrl: remoteUrl ?? item.remoteUrl
)
}
guard var record else { return }
record.status = status
record.progress = min(max(progress, 0), 100)
record.errorMessage = error
if let remoteUrl {
record.remoteUrl = remoteUrl
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)
applyMergedPhotos()
}
private func notifyStateChanged() {
if Thread.isMainThread {
onStateChange?()
} else {
DispatchQueue.main.async { [weak self] in self?.onStateChange?() }
}
}
private func showMessage(_ message: String) {
if Thread.isMainThread {
onShowMessage?(message)
} else {
DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) }
}
}
}
extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
connectionState = state
}
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
currentDriver = driver
}
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
currentDriver = driver
isContentCatalogReady = true
notifyStateChanged()
}
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
showMessage(error.localizedDescription)
}
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
showMessage("长时间未找到相机,请尝试重新插拔连接线")
}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {
guard albumID == albumId else { return }
loadPersistedPhotos()
onLiveShotSaved?(filename)
}
}