将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
263 lines
9.5 KiB
Swift
263 lines
9.5 KiB
Swift
//
|
|
// 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, failedCount: Int, importedPhotoIds: [String])
|
|
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
|
|
var importedPhotoIds: [String] = []
|
|
|
|
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: storage.relativePath(for: downloadedURL, albumId: albumId),
|
|
thumbnailPath: "",
|
|
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
|
|
fileSizeBytes: object.fileSize,
|
|
status: .pending,
|
|
progress: 0,
|
|
albumId: albumId,
|
|
userId: appStore.session.userId
|
|
)
|
|
storage.upsert(record, albumId: albumId)
|
|
existingPhotoIds.insert(object.id)
|
|
importedPhotoIds.append(record.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,
|
|
failedCount: failedPhotoIds.count,
|
|
importedPhotoIds: importedPhotoIds
|
|
)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|