Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/ViewModels/CameraHistoryImportViewModel.swift
汉秋 be9c844bd3 将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 11:22:07 +08:00

221 lines
7.7 KiB
Swift

import Foundation
import Combine
///
struct CameraPhotoSection: Equatable, Identifiable {
var id: String { dayKey }
let dayKey: String
let title: String
let photos: [CameraObject]
}
///
enum CameraImportState: Equatable {
case idle
case loading
case loaded(sections: [CameraPhotoSection])
case importing(current: Int, total: Int)
case finished(importedCount: Int)
case failed(message: String)
}
/// SD ViewModel
@MainActor
final class CameraHistoryImportViewModel: ObservableObject {
@Published private(set) var state: CameraImportState = .idle
@Published private(set) var selectedPhotoIDs: Set<String> = []
@Published private(set) var shouldShowSonyMTPHelp = false
@Published private(set) var sections: [CameraPhotoSection] = []
let albumId: Int
let accountKey: String
private weak var driver: (any CameraDriver)?
private let photoSink: OTGPhotoSink
private let onImported: () -> Void
private var allPhotos: [CameraObject] = []
private var existingAlbumPhotoIDs: Set<String> = []
private var thumbnailCache: [String: Data] = [:]
/// ViewModel
init(
albumId: Int,
accountKey: String,
driver: any CameraDriver,
photoSink: OTGPhotoSink,
onImported: @escaping () -> Void
) {
self.albumId = albumId
self.accountKey = accountKey
self.driver = driver
self.photoSink = photoSink
self.onImported = onImported
}
/// Driver
func loadPhotos() {
guard let driver else {
state = .failed(message: "相机未连接")
return
}
state = .loading
Task {
do {
existingAlbumPhotoIDs = await photoSink.existingCameraObjectIDs()
let objects = try await driver.listObjects()
allPhotos = objects
let sections = Self.groupByDay(objects)
if sections.isEmpty {
self.sections = []
state = .failed(message: "相机中没有可导入的照片")
} else {
self.sections = sections
state = .loaded(sections: sections)
}
updateSonyMTPHelpVisibility()
} catch {
state = .failed(message: error.localizedDescription)
updateSonyMTPHelpVisibility()
}
}
}
///
func isPhotoAlreadyInAlbum(id: String) -> Bool {
existingAlbumPhotoIDs.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: CameraPhotoSection) {
let importableIDs = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id))
guard !importableIDs.isEmpty else { return }
if importableIDs.isSubset(of: selectedPhotoIDs) {
selectedPhotoIDs.subtract(importableIDs)
} else {
selectedPhotoIDs.formUnion(importableIDs)
}
}
func isPhotoSelected(id: String) -> Bool {
selectedPhotoIDs.contains(id)
}
func isSectionFullySelected(_ section: CameraPhotoSection) -> Bool {
let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)
return !ids.isEmpty && ids.allSatisfy { selectedPhotoIDs.contains($0) }
}
func hasImportablePhotos(in section: CameraPhotoSection) -> Bool {
section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) }
}
///
func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? {
if let cached = thumbnailCache[photo.id] {
return cached
}
guard let driver else { return nil }
guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else {
return nil
}
thumbnailCache[photo.id] = data
return data
}
///
func importSelected() {
guard let driver else { return }
let selected = allPhotos.filter {
selectedPhotoIDs.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id)
}
guard !selected.isEmpty else { return }
state = .importing(current: 0, total: selected.count)
Task {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("camera-history-import", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
var imported = 0
for (index, object) in selected.enumerated() {
do {
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
_ = try await photoSink.saveDownloadedFile(
at: downloadedURL,
filename: object.filename,
capturedAt: object.capturedAt,
cameraObjectID: object.id
)
imported += 1
state = .importing(current: index + 1, total: selected.count)
} catch {
OTGLog.error(.connection, "import failed: \(object.filename) \(error.localizedDescription)")
}
}
selectedPhotoIDs.removeAll()
state = .finished(importedCount: imported)
onImported()
}
}
private func updateSonyMTPHelpVisibility() {
guard driver?.platform == .sony else {
shouldShowSonyMTPHelp = false
return
}
switch state {
case .loaded(let sections):
shouldShowSonyMTPHelp = sections.isEmpty
case .failed:
shouldShowSonyMTPHelp = allPhotos.isEmpty
default:
shouldShowSonyMTPHelp = false
}
}
private static func groupByDay(_ photos: [CameraObject]) -> [CameraPhotoSection] {
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
let items = grouped[day]!.sorted { $0.capturedAt > $1.capturedAt }
let dayKey = dayKeyString(for: day, calendar: calendar)
let title = sectionTitle(for: day, formatter: formatter, calendar: calendar)
return CameraPhotoSection(dayKey: dayKey, title: title, photos: items)
}
}
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)
}
}