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