Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift

257 lines
9.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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