Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift
汉秋 5e620623eb 对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 13:53:59 +08:00

283 lines
10 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.

//
// WiredTransferPhotoStore.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// 线 UserDefaults
final class WiredTransferPhotoStore {
private let defaults: UserDefaults
private let accountPrefixProvider: () -> String
private let userIDProvider: () -> String
private static let keyPrefix = "wired_transfer_photos_"
private static let deletedSuffix = "_deleted"
private static let bindingsSuffix = "_photo_bindings"
/// ID
init(
defaults: UserDefaults = .standard,
accountPrefixProvider: @escaping () -> String,
userIDProvider: @escaping () -> String
) {
self.defaults = defaults
self.accountPrefixProvider = accountPrefixProvider
self.userIDProvider = userIDProvider
}
/// ID
func albumID(forPhoto photoID: String) -> Int? {
guard !photoID.isEmpty else { return nil }
return loadBindings().first { $0.photoID == photoID }?.albumID
}
///
func bindPhotoToAlbum(photoID: String, albumID: Int) {
guard !photoID.isEmpty, albumID > 0, !userIDProvider().isEmpty else { return }
var bindings = loadBindings().filter { $0.photoID != photoID }
bindings.append(PhotoAlbumBinding(photoID: photoID, albumID: albumID))
saveBindings(bindings)
}
/// ID
func loadDeletedIDs(albumID: Int) -> Set<String> {
guard albumID > 0, !userIDProvider().isEmpty else { return [] }
let ids = defaults.stringArray(forKey: deletedKey(albumID)) ?? []
return Set(ids)
}
///
func load(albumID: Int) -> [WiredTransferPhotoRecord] {
guard albumID > 0 else { return [] }
let userID = userIDProvider()
guard !userID.isEmpty else { return [] }
let deletedIDs = loadDeletedIDs(albumID: albumID)
guard let data = defaults.data(forKey: storageKey(albumID)),
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
else { return [] }
let filteredRecords = records.filter { record in
!deletedIDs.contains(record.id)
&& record.userId == userID
&& record.albumId == albumID
}
let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() }
let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID)
return migratedRecords
.filter(\.isDisplayable)
.sorted { $0.capturedAt > $1.capturedAt }
.also { items in
items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
}
}
///
private func normalizeStoredPaths(
_ records: [WiredTransferPhotoRecord],
albumID: Int
) -> [WiredTransferPhotoRecord] {
var migrated = records
var needsPersist = false
for index in migrated.indices {
guard let normalized = migrated[index].withNormalizedStoredPaths(),
normalized != migrated[index] else { continue }
migrated[index] = normalized
needsPersist = true
}
if needsPersist {
save(albumID: albumID, records: migrated)
}
return migrated
}
///
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
guard albumID > 0 else { return }
let userID = userIDProvider()
guard !userID.isEmpty else { return }
let normalized = records
.filter { $0.userId == userID && $0.albumId == albumID }
.sorted { $0.capturedAt > $1.capturedAt }
normalized.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
if let data = try? JSONEncoder().encode(normalized) {
defaults.set(data, forKey: storageKey(albumID))
}
}
///
func remove(albumID: Int, photoID: String) {
guard albumID > 0, !photoID.isEmpty else { return }
markDeleted(albumID: albumID, photoID: photoID)
let deletedIDs = loadDeletedIDs(albumID: albumID)
guard let data = defaults.data(forKey: storageKey(albumID)),
var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
else { return }
records
.filter { $0.id == photoID && $0.userId == userIDProvider() && $0.albumId == albumID }
.forEach { deleteRecordFiles($0) }
records = records.filter { record in
!deletedIDs.contains(record.id)
&& record.userId == userIDProvider()
&& record.albumId == albumID
}
save(albumID: albumID, records: records)
}
///
func markDeleted(albumID: Int, photoID: String) {
guard albumID > 0, !photoID.isEmpty, !userIDProvider().isEmpty else { return }
let updated = loadDeletedIDs(albumID: albumID).union([photoID])
defaults.set(Array(updated), forKey: deletedKey(albumID))
}
///
func clearAlbum(albumID: Int) -> Set<String> {
guard albumID > 0 else { return [] }
let userID = userIDProvider()
guard !userID.isEmpty else { return [] }
guard let data = defaults.data(forKey: storageKey(albumID)),
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
else { return [] }
let userRecords = records.filter { $0.userId == userID }
userRecords.forEach { deleteRecordFiles($0) }
var bindings = loadBindings().filter { $0.albumID != albumID }
saveBindings(bindings)
defaults.removeObject(forKey: storageKey(albumID))
defaults.removeObject(forKey: deletedKey(albumID))
return Set(userRecords.map(\.id))
}
private func deleteRecordFiles(_ record: WiredTransferPhotoRecord) {
deleteFileIfExists(record.localPath)
deleteFileIfExists(record.thumbnailPath)
}
private func deleteFileIfExists(_ path: String) {
guard !path.isEmpty else { return }
guard let url = CameraDownloadStorage.resolveLocalURL(from: path) else { return }
if FileManager.default.fileExists(atPath: url.path) {
try? FileManager.default.removeItem(at: url)
}
}
private func storageKey(_ albumID: Int) -> String {
"\(accountPrefixProvider())\(Self.keyPrefix)\(albumID)"
}
private func deletedKey(_ albumID: Int) -> String {
"\(storageKey(albumID))\(Self.deletedSuffix)"
}
private func bindingsKey() -> String {
"\(accountPrefixProvider())\(Self.bindingsSuffix)"
}
private struct PhotoAlbumBinding: Codable {
let photoID: String
let albumID: Int
}
private func loadBindings() -> [PhotoAlbumBinding] {
guard let data = defaults.data(forKey: bindingsKey()),
let bindings = try? JSONDecoder().decode([PhotoAlbumBinding].self, from: data)
else { return [] }
return bindings
}
private func saveBindings(_ bindings: [PhotoAlbumBinding]) {
if let data = try? JSONEncoder().encode(bindings) {
defaults.set(data, forKey: bindingsKey())
}
}
}
private extension WiredTransferPhotoRecord {
var isDisplayable: Bool {
if !remoteURL.isEmpty { return true }
if let url = CameraDownloadStorage.resolveLocalURL(from: localPath),
FileManager.default.fileExists(atPath: url.path) {
return true
}
if let url = CameraDownloadStorage.resolveLocalURL(from: thumbnailPath),
FileManager.default.fileExists(atPath: url.path) {
return true
}
return false
}
/// localPath/thumbnailPath
func withNormalizedStoredPaths() -> WiredTransferPhotoRecord? {
let migratedLocalPath = localPath.isEmpty ? localPath : (CameraDownloadStorage.migrateStoredPath(localPath) ?? localPath)
let migratedThumbnailPath = thumbnailPath.isEmpty
? thumbnailPath
: (CameraDownloadStorage.migrateStoredPath(thumbnailPath) ?? thumbnailPath)
guard migratedLocalPath != localPath || migratedThumbnailPath != thumbnailPath else { return nil }
return WiredTransferPhotoRecord(
id: id,
sourceId: sourceId,
fileName: fileName,
localPath: migratedLocalPath,
thumbnailPath: migratedThumbnailPath,
capturedAt: capturedAt,
fileSizeBytes: fileSizeBytes,
status: status,
progress: progress,
errorMessage: errorMessage,
albumId: albumId,
userId: userId,
remoteURL: remoteURL,
updatedAt: updatedAt
)
}
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
guard status == WiredTransferUploadStatus.transferring.rawValue
|| status == WiredTransferUploadStatus.uploading.rawValue
else { return self }
var copy = self
copy = WiredTransferPhotoRecord(
id: id,
sourceId: sourceId,
fileName: fileName,
localPath: localPath,
thumbnailPath: thumbnailPath,
capturedAt: capturedAt,
fileSizeBytes: fileSizeBytes,
status: remoteURL.isEmpty ? WiredTransferUploadStatus.pending.rawValue : WiredTransferUploadStatus.uploaded.rawValue,
progress: remoteURL.isEmpty ? 0 : 100,
errorMessage: errorMessage,
albumId: albumId,
userId: userId,
remoteURL: remoteURL,
updatedAt: updatedAt
)
return copy
}
}
private extension Array {
@discardableResult
func also(_ body: (Self) -> Void) -> Self {
body(self)
return self
}
}