对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。

重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 13:53:59 +08:00
parent c71b45bdfd
commit 5e620623eb
23 changed files with 2000 additions and 288 deletions

View File

@ -16,9 +16,14 @@ final class CameraTransferPipeline {
private let cameraService: any CameraServiceProtocol
private var uploadSink: (any CameraAssetUploadSink)?
private var uploadEnabled = true
private var isProcessing = false
private var activeUploadAssetIDs: Set<String> = []
private var pendingDeferredNotifyTask: Task<Void, Never>?
private var lastDeferredNotifyDate = Date.distantPast
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
private var inFlightAssetIDs: Set<String> = []
private let maxRetries = 3
private let deferredNotifyInterval: TimeInterval = 0.15
private let maxConcurrentUploads = 3
///
init(cameraService: any CameraServiceProtocol) {
@ -67,25 +72,62 @@ final class CameraTransferPipeline {
tasks[index].errorMessage = nil
}
notify()
await processUploadQueue()
processUploadQueue()
}
/// asset
func uploadAssets(withIDs assetIDs: [String]) async {
guard uploadEnabled, uploadSink != nil else { return }
guard uploadSink != nil else { return }
for assetID in assetIDs {
if activeUploadAssetIDs.contains(assetID) { continue }
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
if tasks[index].status == .uploaded { continue }
if tasks[index].localURL == nil {
await downloadAsset(at: index)
await downloadAsset(assetID: assetID)
}
if tasks[index].status == .downloaded || tasks[index].status == .failed {
tasks[index].status = .downloaded
await uploadTask(at: index)
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
tasks[refreshedIndex].status = .downloaded
tasks[refreshedIndex].errorMessage = nil
await uploadTask(assetID: assetID)
}
}
}
///
func retryUpload(assetID: String, filename: String, localPath: String) async {
guard uploadSink != nil else { return }
guard !activeUploadAssetIDs.contains(assetID) else { return }
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
if tasks[index].status == .uploaded { return }
if tasks[index].localPath?.isEmpty != false, !localPath.isEmpty {
tasks[index].localPath = localPath
}
tasks[index].status = .downloaded
tasks[index].errorMessage = nil
} else {
guard !localPath.isEmpty,
let localURL = CameraDownloadStorage.resolveLocalURL(from: localPath),
FileManager.default.fileExists(atPath: localURL.path) else {
return
}
tasks.insert(
CameraTransferTask(
assetID: assetID,
filename: filename,
localPath: localPath,
capturedAt: CameraTransferTask.capturedAtString(forLocalPath: localPath),
status: .downloaded
),
at: 0
)
}
notify()
await uploadTask(assetID: assetID)
}
///
func syncExistingPhotos() async {
guard cameraService.connectionState.isConnected else { return }
@ -128,28 +170,33 @@ final class CameraTransferPipeline {
inFlightAssetIDs.insert(asset.id)
defer { inFlightAssetIDs.remove(asset.id) }
let taskIndex: Int
if let existingIndex = tasks.firstIndex(where: { $0.assetID == asset.id }) {
taskIndex = existingIndex
tasks[taskIndex].status = .downloading
tasks[taskIndex].errorMessage = nil
let assetID = asset.id
if let existingIndex = tasks.firstIndex(where: { $0.assetID == assetID }) {
tasks[existingIndex].status = .downloading
tasks[existingIndex].errorMessage = nil
} else {
let task = CameraTransferTask(assetID: asset.id, filename: asset.filename, status: .downloading)
let task = CameraTransferTask(
assetID: asset.id,
filename: asset.filename,
capturedAt: CameraTransferTask.capturedAtString(from: asset.creationDate),
status: .downloading
)
tasks.insert(task, at: 0)
taskIndex = 0
}
notify()
await downloadAsset(at: taskIndex, asset: asset)
await downloadAsset(assetID: assetID, asset: asset)
if uploadEnabled, uploadSink != nil, tasks[taskIndex].status == .downloaded {
await processUploadQueue()
if uploadEnabled,
uploadSink != nil,
tasks.first(where: { $0.assetID == assetID })?.status == .downloaded {
processUploadQueue()
}
}
private func downloadAsset(at index: Int, asset: CameraAsset? = nil) async {
guard tasks.indices.contains(index) else { return }
let assetID = tasks[index].assetID
/// asset assetID index
private func downloadAsset(assetID: String, asset: CameraAsset? = nil) async {
guard tasks.contains(where: { $0.assetID == assetID }) else { return }
do {
let resolvedAsset: CameraAsset
@ -174,31 +221,44 @@ final class CameraTransferPipeline {
try FileManager.default.moveItem(at: localURL, to: destination)
}
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
tasks[index].localPath = CameraDownloadStorage.relativePath(for: destination)
if tasks[index].capturedAt?.isEmpty != false {
tasks[index].capturedAt = CameraTransferTask.capturedAtString(from: resolvedAsset.creationDate)
}
tasks[index].status = .downloaded
tasks[index].progress = 0
notify()
} catch {
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
tasks[index].status = .failed
tasks[index].errorMessage = error.localizedDescription
notify()
}
}
private func processUploadQueue() async {
///
private func processUploadQueue() {
guard uploadEnabled, uploadSink != nil else { return }
guard !isProcessing else { return }
isProcessing = true
defer { isProcessing = false }
let pendingIndices = tasks.indices.filter { tasks[$0].status == .downloaded }
for index in pendingIndices {
await uploadTask(at: index)
while activeUploadAssetIDs.count < maxConcurrentUploads,
let task = tasks.first(where: { $0.status == .downloaded && !activeUploadAssetIDs.contains($0.assetID) }) {
startConcurrentUpload(assetID: task.assetID)
}
}
private func uploadTask(at index: Int) async {
guard tasks.indices.contains(index),
private func startConcurrentUpload(assetID: String) {
guard !activeUploadAssetIDs.contains(assetID) else { return }
activeUploadAssetIDs.insert(assetID)
Task { @MainActor in
await self.uploadTask(assetID: assetID)
self.activeUploadAssetIDs.remove(assetID)
self.processUploadQueue()
}
}
private func uploadTask(assetID: String) async {
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }),
let sink = uploadSink,
let localURL = tasks[index].localURL
else { return }
@ -207,33 +267,41 @@ final class CameraTransferPipeline {
tasks[index].errorMessage = nil
notify()
let fileType = tasks[index].filename.lowercased().hasSuffix(".mp4") ? 1 : 2
let filename = tasks[index].filename
let fileType = filename.lowercased().hasSuffix(".mp4") ? 1 : 2
var attempt = 0
while attempt < maxRetries {
do {
let remoteURL = try await sink.upload(
localURL: localURL,
fileName: tasks[index].filename,
fileName: filename,
fileType: fileType,
progress: { [weak self] progress in
Task { @MainActor in
guard let self, self.tasks.indices.contains(index) else { return }
self.tasks[index].progress = progress
self.notify()
guard let self,
let taskIndex = self.tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
let lastNotifiedProgress = self.lastNotifiedProgressByAssetID[assetID] ?? -1
guard progress == 100 || progress - lastNotifiedProgress >= 10 else { return }
self.lastNotifiedProgressByAssetID[assetID] = progress
self.tasks[taskIndex].progress = progress
self.notify(progress == 100 ? .immediate : .deferred)
}
}
)
tasks[index].status = .uploaded
tasks[index].remoteURL = remoteURL
tasks[index].progress = 100
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
tasks[taskIndex].status = .uploaded
tasks[taskIndex].remoteURL = remoteURL
tasks[taskIndex].progress = 100
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
notify()
return
} catch {
attempt += 1
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
if attempt >= maxRetries {
tasks[index].status = .failed
tasks[index].errorMessage = error.localizedDescription
tasks[taskIndex].status = .failed
tasks[taskIndex].errorMessage = error.localizedDescription
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
notify()
} else {
let delay = UInt64(pow(2.0, Double(attempt)))
@ -243,7 +311,33 @@ final class CameraTransferPipeline {
}
}
private func notify() {
onTasksUpdated?(tasks)
private func notify(_ mode: CameraTransferNotifyMode = .immediate) {
switch mode {
case .immediate:
pendingDeferredNotifyTask?.cancel()
pendingDeferredNotifyTask = nil
onTasksUpdated?(tasks)
case .deferred:
let elapsed = Date().timeIntervalSince(lastDeferredNotifyDate)
if elapsed >= deferredNotifyInterval {
lastDeferredNotifyDate = Date()
onTasksUpdated?(tasks)
return
}
guard pendingDeferredNotifyTask == nil else { return }
let delay = UInt64((deferredNotifyInterval - elapsed) * 1_000_000_000)
pendingDeferredNotifyTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
self.lastDeferredNotifyDate = Date()
self.pendingDeferredNotifyTask = nil
self.onTasksUpdated?(self.tasks)
}
}
}
}
private enum CameraTransferNotifyMode {
case immediate
case deferred
}

View File

@ -24,6 +24,8 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
let filename: String
var localPath: String?
var remoteURL: String?
/// `yyyy-MM-dd HH:mm:ss`
var capturedAt: String?
var status: CameraTransferStatus
var progress: Int
var errorMessage: String?
@ -34,6 +36,7 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
filename: String,
localPath: String? = nil,
remoteURL: String? = nil,
capturedAt: String? = nil,
status: CameraTransferStatus = .pending,
progress: Int = 0,
errorMessage: String? = nil
@ -43,11 +46,36 @@ struct CameraTransferTask: Identifiable, Equatable, Codable {
self.filename = filename
self.localPath = localPath
self.remoteURL = remoteURL
self.capturedAt = capturedAt
self.status = status
self.progress = progress
self.errorMessage = errorMessage
}
/// 使
static func capturedAtString(from date: Date?) -> String {
Self.capturedAtFormatter.string(from: date ?? Date())
}
///
static func capturedAtString(forLocalPath path: String) -> String? {
guard let url = CameraDownloadStorage.resolveLocalURL(from: path),
FileManager.default.fileExists(atPath: url.path),
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
else { return nil }
let date = (attributes[.creationDate] as? Date)
?? (attributes[.modificationDate] as? Date)
return date.map { capturedAtString(from: $0) }
}
private static let capturedAtFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
/// URL
var localURL: URL? {
guard let localPath, !localPath.isEmpty else { return nil }