将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 有线传图页展示的相机连接状态(UI 层使用,OTG 连接能力已移除)。
|
||||
/// 有线传图页展示的相机连接状态(UI 层使用)。
|
||||
enum CameraConnectionState: Equatable {
|
||||
case disconnected
|
||||
case searching
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// 单条索尼 MTP 帮助说明段落。
|
||||
struct SonyMTPHelpSection: Equatable, Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
/// 索尼历史照片导入需 MTP 模式的说明文案。
|
||||
enum SonyMTPHelpContent {
|
||||
static let navigationTitle = "索尼 MTP 导入说明"
|
||||
|
||||
static let introduction = """
|
||||
当前可能处于「电脑遥控(PC Remote)」等 USB 模式,App 无法读取 SD 卡历史照片。\
|
||||
若要使用「历史导入」浏览相机内已有照片,请先将相机切换为 MTP 模式。
|
||||
"""
|
||||
|
||||
static let sections: [SonyMTPHelpSection] = [
|
||||
SonyMTPHelpSection(
|
||||
title: "1. USB 连接模式",
|
||||
body: "进入相机菜单:设置 → USB 连接 → 选择「MTP」或「自动」(优先 MTP)。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "2. 关闭遥控连接",
|
||||
body: "关闭:网络 → 电脑遥控、智能手机连接。避免相机仍处于 PC Remote 传图状态。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "3. 保存目的地",
|
||||
body: "静态影像保存目的地勿选「仅电脑」;建议「仅拍摄装置」或「电脑+拍摄装置」。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "4. 重新连接",
|
||||
body: "修改设置后,拔插 USB 线,回到 App 重新连接相机,再点击「历史导入」。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "5. 说明",
|
||||
body: "MTP 连接期间机身可能无法按快门。边拍边传请使用 PC Remote 模式,历史导入请使用 MTP 模式。"
|
||||
)
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
/// 将 OTG 引擎连接状态映射为有线传图页展示状态。
|
||||
extension ConnectionState {
|
||||
/// 转换为旅拍有线传图 UI 使用的连接状态。
|
||||
var cameraConnectionState: CameraConnectionState {
|
||||
switch self {
|
||||
case .idle, .disconnected:
|
||||
return .disconnected
|
||||
case .searching:
|
||||
return .searching
|
||||
case .connecting:
|
||||
return .connecting
|
||||
case .connected(_, let deviceName):
|
||||
return .connected(deviceName: deviceName)
|
||||
case .failed(let message):
|
||||
return .error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
|
||||
/// OTG 连接与旅拍有线传图管道之间的协调器。
|
||||
@MainActor
|
||||
final class OTGLiveTransferCoordinator: NSObject, ConnectionManagerDelegate {
|
||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
||||
var onContentCatalogReady: (() -> Void)?
|
||||
var onLiveShotSaved: ((SavedOTGPhoto) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
var onSuggestReplug: (() -> Void)?
|
||||
|
||||
private(set) var currentDriver: (any CameraDriver)?
|
||||
private(set) var isContentCatalogReady = false
|
||||
|
||||
private let connectionManager: ConnectionManager
|
||||
private let pipeline: WiredCameraTransferPipeline
|
||||
private var photoSink: TravelAlbumOTGPhotoSink?
|
||||
private var liveContext: OTGLiveTransferContext?
|
||||
|
||||
/// 初始化协调器。
|
||||
init(
|
||||
connectionManager: ConnectionManager = .shared,
|
||||
pipeline: WiredCameraTransferPipeline
|
||||
) {
|
||||
self.connectionManager = connectionManager
|
||||
self.pipeline = pipeline
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 配置边拍边传上下文并绑定 ConnectionManager。
|
||||
func start(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
autoUploadProvider: @escaping () -> Bool,
|
||||
existingObjectIDsProvider: @escaping () async -> Set<String>
|
||||
) {
|
||||
let sink = TravelAlbumOTGPhotoSink(
|
||||
albumId: albumId,
|
||||
accountKey: accountKey,
|
||||
pipeline: pipeline,
|
||||
existingObjectIDsProvider: existingObjectIDsProvider,
|
||||
autoUploadProvider: autoUploadProvider
|
||||
)
|
||||
photoSink = sink
|
||||
liveContext = OTGLiveTransferContext(albumId: albumId, accountKey: accountKey, photoSink: sink)
|
||||
|
||||
connectionManager.configureLiveTransfer(context: liveContext)
|
||||
connectionManager.delegate = self
|
||||
syncFromManager()
|
||||
connectionManager.start()
|
||||
}
|
||||
|
||||
/// 解绑 UI 回调,保持设备缓存。
|
||||
func detach() {
|
||||
connectionManager.unbindDelegate()
|
||||
connectionManager.delegate = nil
|
||||
}
|
||||
|
||||
/// 主动断开相机连接。
|
||||
func disconnect() {
|
||||
connectionManager.disconnect()
|
||||
liveContext = nil
|
||||
photoSink = nil
|
||||
}
|
||||
|
||||
/// 重新搜索 USB 相机。
|
||||
func restartSearch() {
|
||||
connectionManager.start()
|
||||
syncFromManager()
|
||||
}
|
||||
|
||||
/// 当前连接状态(旅拍 UI 格式)。
|
||||
var connectionState: CameraConnectionState {
|
||||
connectionManager.state.cameraConnectionState
|
||||
}
|
||||
|
||||
/// 创建历史导入 ViewModel。
|
||||
func makeHistoryImportViewModel(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
onImported: @escaping () -> Void
|
||||
) -> CameraHistoryImportViewModel? {
|
||||
guard let driver = currentDriver, isContentCatalogReady, let photoSink else { return nil }
|
||||
return CameraHistoryImportViewModel(
|
||||
albumId: albumId,
|
||||
accountKey: accountKey,
|
||||
driver: driver,
|
||||
photoSink: photoSink,
|
||||
onImported: onImported
|
||||
)
|
||||
}
|
||||
|
||||
private func syncFromManager() {
|
||||
currentDriver = connectionManager.currentDriver
|
||||
isContentCatalogReady = connectionManager.isContentCatalogReady
|
||||
onConnectionStateChange?(connectionManager.state.cameraConnectionState)
|
||||
if isContentCatalogReady {
|
||||
onContentCatalogReady?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConnectionManagerDelegate
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
|
||||
onConnectionStateChange?(state.cameraConnectionState)
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
|
||||
currentDriver = driver
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
|
||||
onError?(error.localizedDescription)
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
|
||||
currentDriver = driver
|
||||
isContentCatalogReady = true
|
||||
onContentCatalogReady?()
|
||||
}
|
||||
|
||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
|
||||
onSuggestReplug?()
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int) {
|
||||
onLiveShotSaved?(photo)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册 OTG 照片落盘实现:写入 scoped 本地目录并通知传输管道。
|
||||
@MainActor
|
||||
final class TravelAlbumOTGPhotoSink: OTGPhotoSink {
|
||||
private let albumId: Int
|
||||
private let accountKey: String
|
||||
private let pipeline: WiredCameraTransferPipeline
|
||||
private let existingObjectIDsProvider: () async -> Set<String>
|
||||
private let autoUploadProvider: () -> Bool
|
||||
|
||||
/// 初始化旅拍相册 OTG 落盘实现。
|
||||
init(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
pipeline: WiredCameraTransferPipeline,
|
||||
existingObjectIDsProvider: @escaping () async -> Set<String>,
|
||||
autoUploadProvider: @escaping () -> Bool
|
||||
) {
|
||||
self.albumId = albumId
|
||||
self.accountKey = accountKey
|
||||
self.pipeline = pipeline
|
||||
self.existingObjectIDsProvider = existingObjectIDsProvider
|
||||
self.autoUploadProvider = autoUploadProvider
|
||||
}
|
||||
|
||||
/// 保存边拍边传图像数据。
|
||||
func saveLiveShot(
|
||||
data: Data,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto {
|
||||
let fileURL = try OTGPhotoFileWriter.writeImage(
|
||||
data,
|
||||
filename: filename,
|
||||
accountKey: accountKey,
|
||||
albumID: albumId
|
||||
)
|
||||
return ingestSavedFile(
|
||||
fileURL: fileURL,
|
||||
filename: filename,
|
||||
capturedAt: capturedAt,
|
||||
cameraObjectID: cameraObjectID
|
||||
)
|
||||
}
|
||||
|
||||
/// 保存历史导入已下载文件。
|
||||
func saveDownloadedFile(
|
||||
at url: URL,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto {
|
||||
let fileURL = try OTGPhotoFileWriter.moveDownloadedFile(
|
||||
from: url,
|
||||
filename: filename,
|
||||
accountKey: accountKey,
|
||||
albumID: albumId
|
||||
)
|
||||
return ingestSavedFile(
|
||||
fileURL: fileURL,
|
||||
filename: filename,
|
||||
capturedAt: capturedAt,
|
||||
cameraObjectID: cameraObjectID
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回当前相册已导入的相机对象 ID。
|
||||
func existingCameraObjectIDs() async -> Set<String> {
|
||||
await existingObjectIDsProvider()
|
||||
}
|
||||
|
||||
private func ingestSavedFile(
|
||||
fileURL: URL,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) -> SavedOTGPhoto {
|
||||
let relativePath = OTGPhotoFileWriter.relativePath(for: fileURL)
|
||||
let autoUpload = autoUploadProvider()
|
||||
pipeline.ingestDownloadedAsset(
|
||||
assetID: cameraObjectID,
|
||||
filename: filename,
|
||||
localPath: relativePath,
|
||||
capturedAt: capturedAt,
|
||||
autoUpload: autoUpload
|
||||
)
|
||||
return SavedOTGPhoto(
|
||||
assetID: cameraObjectID,
|
||||
relativeLocalPath: relativePath,
|
||||
filename: filename,
|
||||
capturedAt: capturedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,96 @@ final class WiredCameraTransferPipeline {
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
}
|
||||
|
||||
/// 将相机已下载到本地的照片纳入传输管道。
|
||||
func ingestDownloadedAsset(
|
||||
assetID: String,
|
||||
filename: String,
|
||||
localPath: String,
|
||||
capturedAt: Date?,
|
||||
autoUpload: Bool
|
||||
) {
|
||||
guard !excludedAssetIDs.contains(assetID) else { return }
|
||||
|
||||
let capturedAtString = capturedAt.map { CameraTransferTask.capturedAtString(from: $0) }
|
||||
?? CameraTransferTask.capturedAtString(forLocalPath: localPath)
|
||||
|
||||
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
tasks[index].localPath = localPath
|
||||
if tasks[index].capturedAt?.isEmpty != false {
|
||||
tasks[index].capturedAt = capturedAtString
|
||||
}
|
||||
if tasks[index].status != .uploaded {
|
||||
tasks[index].status = .downloaded
|
||||
}
|
||||
tasks[index].errorMessage = nil
|
||||
} else {
|
||||
tasks.insert(
|
||||
CameraTransferTask(
|
||||
assetID: assetID,
|
||||
filename: filename,
|
||||
localPath: localPath,
|
||||
capturedAt: capturedAtString,
|
||||
status: .downloaded
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
if autoUpload, uploadEnabled, uploadSink != nil {
|
||||
autoUploadEligibleAssetIDs.insert(assetID)
|
||||
processUploadQueue()
|
||||
}
|
||||
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 标记 asset 正在从相机下载。
|
||||
func markDownloading(assetID: String, filename: String) {
|
||||
guard !excludedAssetIDs.contains(assetID) else { return }
|
||||
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
tasks[index].status = .downloading
|
||||
tasks[index].errorMessage = nil
|
||||
} else {
|
||||
tasks.insert(
|
||||
CameraTransferTask(assetID: assetID, filename: filename, status: .downloading),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 标记 asset 下载失败。
|
||||
func failDownload(assetID: String, message: String) {
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
tasks[index].status = .failed
|
||||
tasks[index].errorMessage = message
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 并发上传边拍边传新照片;拍后传输任务只能由用户显式上传。
|
||||
private func processUploadQueue() {
|
||||
guard uploadSink != nil else { return }
|
||||
|
||||
while activeUploadAssetIDs.count < maxConcurrentUploads,
|
||||
let task = tasks.first(where: {
|
||||
$0.status == .downloaded
|
||||
&& !activeUploadAssetIDs.contains($0.assetID)
|
||||
&& autoUploadEligibleAssetIDs.contains($0.assetID)
|
||||
}) {
|
||||
startConcurrentUpload(assetID: task.assetID)
|
||||
}
|
||||
}
|
||||
|
||||
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 uploadAssetIfNeeded(assetID: String) async {
|
||||
if activeUploadAssetIDs.contains(assetID) { return }
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
|
||||
@ -47,32 +47,50 @@ WiredTransferHeaderView # 蓝色顶栏:相册名 + 手机号
|
||||
WiredTransferControlCard # 浮动白卡片:设备存储 / 连接状态 / 筛选 Chip / Tab
|
||||
WiredTransferSelectAllBar # 勾选模式全选栏(条件显示)
|
||||
WiredTransferPhotoListPanel # 时间侧栏 + 30 分钟分组列表
|
||||
WiredTransferBottomBar # 批量上传 + 指定上传
|
||||
WiredTransferBottomBar # 批量上传 + 指定上传 + 历史导入
|
||||
SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
CameraHistoryImportView # 相机 SD 卡历史导入全屏 Sheet
|
||||
SonyMTPHelpView # Sony MTP 模式帮助说明
|
||||
```
|
||||
|
||||
## 有线传图交互流程
|
||||
|
||||
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 刷新本地持久化记录
|
||||
2. **批量上传三态**:
|
||||
1. **USB 连接**:进入页面后 `OTGLiveTransferCoordinator` 驱动 `Core/CameraOTG` 的 `ConnectionManager` 搜索并连接 Sony / Canon / Nikon 相机;连接成功 Toast 提示,断开时弹窗警告
|
||||
2. **刷新**:catalog 就绪后点击「刷新」→ `refreshCameraFiles()` 同步相机内已有照片(拍后传输模式仅下载不上传);未连接时刷新本地持久化记录
|
||||
3. **历史导入**:底部「历史导入」在 `catalogReady && 已连接` 时可点,打开 `CameraHistoryImportView` 按日多选导入;Sony catalog 为空时展示 MTP 帮助入口
|
||||
4. **批量上传三态**:
|
||||
- 首次点击 → 进入勾选模式
|
||||
- 有选中项 → 上传选中照片
|
||||
- 无选中项 → 取消选择
|
||||
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||
5. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||
- 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材
|
||||
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||
- 「边拍边传」只自动上传该模式下由相机新照片事件发现的照片
|
||||
- 「拍后传输」下载到 App 的旧照片切换模式后不会自动补传,需通过批量上传、指定上传、勾选上传或单张重试显式上传
|
||||
6. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||
7. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||
8. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||
- 「边拍边传」:相机新拍照片经 OTG 落盘后自动进入上传队列(最多 3 张并发)
|
||||
- 「拍后传输」:下载到 App 的照片不自动上传,需通过批量上传、指定上传、勾选上传或单张重试显式上传
|
||||
9. **前后台**:App 回到前台且未连接时自动重新搜索相机;离开页面仅 detach UI,保持设备缓存(与 demo 一致)
|
||||
|
||||
## OTG 与上传管道
|
||||
|
||||
```
|
||||
WiredCameraTransferView → WiredCameraTransferViewModel
|
||||
→ OTGLiveTransferCoordinator → ConnectionManager (Core/CameraOTG)
|
||||
→ TravelAlbumOTGPhotoSink → WiredCameraTransferPipeline → OSS 上传
|
||||
CameraHistoryImportView → CameraHistoryImportViewModel → CameraDriver + OTGPhotoSink
|
||||
```
|
||||
|
||||
- OTG 引擎详见 `Core/CameraOTG/CameraOTG.md`
|
||||
- 边拍边传路径:`ConnectionManager` live shot → `OTGPhotoSink` → `pipeline.ingestDownloadedAsset(autoUpload:)` → `TravelAlbumMaterialUploader`
|
||||
- 历史导入路径:批量下载落盘,不自动上传,导入完成后刷新主列表
|
||||
|
||||
## 业务流程
|
||||
|
||||
1. 创建任务:`POST .../travel-album/create`
|
||||
2. 进入有线传图:有线传图 UI 页(USB/OTG 相机连接能力已移除,保留页面与本地照片上传)
|
||||
2. 进入有线传图:USB 连接相机,边拍边传或拍后传输 / 历史导入
|
||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||
4. 素材登记:`POST .../travel-album/upload-material`
|
||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(`CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`)
|
||||
|
||||
## 有线传图性能策略
|
||||
|
||||
@ -85,16 +103,17 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
|
||||
- `WiredCameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
||||
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
|
||||
- `OTGLiveTransferCoordinator` 桥接 `Core/CameraOTG` 与旅拍上传管道,通过 `OTGPhotoSink` 注入存储
|
||||
- 与「相册管理」(`Features/Assets`)API、Store、路由完全隔离
|
||||
|
||||
## 依赖模块
|
||||
|
||||
- `Features/TravelAlbum/Services` — 本地存储、上传编排
|
||||
- `Core/CameraOTG` — USB 连接、品牌 Driver、边拍边传、PTP
|
||||
- `Features/TravelAlbum/Services` — 本地存储、上传编排、OTG 协调器
|
||||
- `Core/Upload` — OSS STS 上传
|
||||
|
||||
## 二期待补齐
|
||||
|
||||
- RAW 预览
|
||||
- USB/OTG 相机有线连接与边拍边传
|
||||
- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `WiredCameraTransferPipeline` 联动
|
||||
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)
|
||||
|
||||
@ -0,0 +1,220 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 按日分组的相机照片区块。
|
||||
struct CameraPhotoSection: Equatable, Identifiable {
|
||||
var id: String { dayKey }
|
||||
let dayKey: String
|
||||
let title: String
|
||||
let photos: [CameraObject]
|
||||
}
|
||||
|
||||
/// 相机历史导入页状态机。
|
||||
enum CameraImportState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(sections: [CameraPhotoSection])
|
||||
case importing(current: Int, total: Int)
|
||||
case finished(importedCount: Int)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
/// 相机 SD 卡历史照片多选导入 ViewModel。
|
||||
@MainActor
|
||||
final class CameraHistoryImportViewModel: ObservableObject {
|
||||
@Published private(set) var state: CameraImportState = .idle
|
||||
@Published private(set) var selectedPhotoIDs: Set<String> = []
|
||||
@Published private(set) var shouldShowSonyMTPHelp = false
|
||||
@Published private(set) var sections: [CameraPhotoSection] = []
|
||||
|
||||
let albumId: Int
|
||||
let accountKey: String
|
||||
private weak var driver: (any CameraDriver)?
|
||||
private let photoSink: OTGPhotoSink
|
||||
private let onImported: () -> Void
|
||||
|
||||
private var allPhotos: [CameraObject] = []
|
||||
private var existingAlbumPhotoIDs: Set<String> = []
|
||||
private var thumbnailCache: [String: Data] = [:]
|
||||
|
||||
/// 初始化历史导入 ViewModel。
|
||||
init(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
driver: any CameraDriver,
|
||||
photoSink: OTGPhotoSink,
|
||||
onImported: @escaping () -> Void
|
||||
) {
|
||||
self.albumId = albumId
|
||||
self.accountKey = accountKey
|
||||
self.driver = driver
|
||||
self.photoSink = photoSink
|
||||
self.onImported = onImported
|
||||
}
|
||||
|
||||
/// 通过 Driver 拉取相机内照片并按日分组。
|
||||
func loadPhotos() {
|
||||
guard let driver else {
|
||||
state = .failed(message: "相机未连接")
|
||||
return
|
||||
}
|
||||
|
||||
state = .loading
|
||||
|
||||
Task {
|
||||
do {
|
||||
existingAlbumPhotoIDs = await photoSink.existingCameraObjectIDs()
|
||||
let objects = try await driver.listObjects()
|
||||
allPhotos = objects
|
||||
let sections = Self.groupByDay(objects)
|
||||
if sections.isEmpty {
|
||||
self.sections = []
|
||||
state = .failed(message: "相机中没有可导入的照片")
|
||||
} else {
|
||||
self.sections = sections
|
||||
state = .loaded(sections: sections)
|
||||
}
|
||||
updateSonyMTPHelpVisibility()
|
||||
} catch {
|
||||
state = .failed(message: error.localizedDescription)
|
||||
updateSonyMTPHelpVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断照片是否已在当前相册。
|
||||
func isPhotoAlreadyInAlbum(id: String) -> Bool {
|
||||
existingAlbumPhotoIDs.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: CameraPhotoSection) {
|
||||
let importableIDs = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id))
|
||||
guard !importableIDs.isEmpty else { return }
|
||||
if importableIDs.isSubset(of: selectedPhotoIDs) {
|
||||
selectedPhotoIDs.subtract(importableIDs)
|
||||
} else {
|
||||
selectedPhotoIDs.formUnion(importableIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func isPhotoSelected(id: String) -> Bool {
|
||||
selectedPhotoIDs.contains(id)
|
||||
}
|
||||
|
||||
func isSectionFullySelected(_ section: CameraPhotoSection) -> Bool {
|
||||
let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)
|
||||
return !ids.isEmpty && ids.allSatisfy { selectedPhotoIDs.contains($0) }
|
||||
}
|
||||
|
||||
func hasImportablePhotos(in section: CameraPhotoSection) -> Bool {
|
||||
section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) }
|
||||
}
|
||||
|
||||
/// 加载相机缩略图。
|
||||
func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
if let cached = thumbnailCache[photo.id] {
|
||||
return cached
|
||||
}
|
||||
guard let driver else { return nil }
|
||||
guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else {
|
||||
return nil
|
||||
}
|
||||
thumbnailCache[photo.id] = data
|
||||
return data
|
||||
}
|
||||
|
||||
/// 顺序下载选中项并写入本地存储。
|
||||
func importSelected() {
|
||||
guard let driver else { return }
|
||||
let selected = allPhotos.filter {
|
||||
selectedPhotoIDs.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id)
|
||||
}
|
||||
guard !selected.isEmpty else { return }
|
||||
|
||||
state = .importing(current: 0, total: selected.count)
|
||||
|
||||
Task {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("camera-history-import", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
var imported = 0
|
||||
|
||||
for (index, object) in selected.enumerated() {
|
||||
do {
|
||||
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
|
||||
_ = try await photoSink.saveDownloadedFile(
|
||||
at: downloadedURL,
|
||||
filename: object.filename,
|
||||
capturedAt: object.capturedAt,
|
||||
cameraObjectID: object.id
|
||||
)
|
||||
imported += 1
|
||||
state = .importing(current: index + 1, total: selected.count)
|
||||
} catch {
|
||||
OTGLog.error(.connection, "import failed: \(object.filename) \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
selectedPhotoIDs.removeAll()
|
||||
state = .finished(importedCount: imported)
|
||||
onImported()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSonyMTPHelpVisibility() {
|
||||
guard driver?.platform == .sony else {
|
||||
shouldShowSonyMTPHelp = false
|
||||
return
|
||||
}
|
||||
switch state {
|
||||
case .loaded(let sections):
|
||||
shouldShowSonyMTPHelp = sections.isEmpty
|
||||
case .failed:
|
||||
shouldShowSonyMTPHelp = allPhotos.isEmpty
|
||||
default:
|
||||
shouldShowSonyMTPHelp = false
|
||||
}
|
||||
}
|
||||
|
||||
private static func groupByDay(_ photos: [CameraObject]) -> [CameraPhotoSection] {
|
||||
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
|
||||
let items = grouped[day]!.sorted { $0.capturedAt > $1.capturedAt }
|
||||
let dayKey = dayKeyString(for: day, calendar: calendar)
|
||||
let title = sectionTitle(for: day, formatter: formatter, calendar: calendar)
|
||||
return CameraPhotoSection(dayKey: dayKey, title: title, photos: items)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -434,6 +434,9 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
@Published var showDeviceStorageAlert = false
|
||||
@Published var showCameraDisconnectedAlert = false
|
||||
@Published var showHelpDoc = false
|
||||
@Published var showHistoryImport = false
|
||||
@Published var showDisconnectCamera = false
|
||||
@Published private(set) var isContentCatalogReady = false
|
||||
@Published var isRefreshingCameraFiles = false
|
||||
@Published var isSearchingCamera = false
|
||||
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||
@ -442,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
|
||||
let context: WiredTransferContext
|
||||
private let pipeline = WiredCameraTransferPipeline()
|
||||
private lazy var otgCoordinator = OTGLiveTransferCoordinator(pipeline: pipeline)
|
||||
private var photoStore: WiredTransferPhotoStore?
|
||||
private var userIDProvider: (() -> String)?
|
||||
private var downloadScope: CameraDownloadStorage.Scope?
|
||||
@ -502,7 +506,21 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||
refreshDeviceStorageInfo()
|
||||
loadPersistedPhotos()
|
||||
connectionState = .disconnected
|
||||
|
||||
bindOTGCoordinator()
|
||||
otgCoordinator.start(
|
||||
albumId: context.albumId,
|
||||
accountKey: accountCachePrefix,
|
||||
autoUploadProvider: { [weak self] in
|
||||
self?.transferModeOption == Self.modeLiveCapture
|
||||
},
|
||||
existingObjectIDsProvider: { [weak self] in
|
||||
await self?.existingCameraObjectIDs() ?? []
|
||||
}
|
||||
)
|
||||
connectionState = otgCoordinator.connectionState
|
||||
isContentCatalogReady = otgCoordinator.isContentCatalogReady
|
||||
wasConnected = connectionState.isConnected
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
@ -510,6 +528,37 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
detachFromUI()
|
||||
}
|
||||
|
||||
private func bindOTGCoordinator() {
|
||||
otgCoordinator.onConnectionStateChange = { [weak self] state in
|
||||
self?.handleConnectionStateChange(state)
|
||||
}
|
||||
otgCoordinator.onContentCatalogReady = { [weak self] in
|
||||
self?.isContentCatalogReady = true
|
||||
}
|
||||
otgCoordinator.onLiveShotSaved = { [weak self] (_: SavedOTGPhoto) in
|
||||
self?.refreshDeviceStorageInfo()
|
||||
}
|
||||
otgCoordinator.onError = { [weak self] message in
|
||||
self?.errorMessage = message
|
||||
}
|
||||
otgCoordinator.onSuggestReplug = { [weak self] in
|
||||
self?.errorMessage = "长时间未找到相机,请尝试重新插拔连接线"
|
||||
}
|
||||
}
|
||||
|
||||
private func existingCameraObjectIDs() async -> Set<String> {
|
||||
var ids = Set<String>()
|
||||
for record in persistedRecordsByID.values {
|
||||
if let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(localPath: localURL.path) {
|
||||
ids.insert(objectID)
|
||||
} else {
|
||||
ids.insert(record.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// 页面离开:仅 detach UI 回调,保持相机连接。
|
||||
func stop() async {
|
||||
detachFromUI()
|
||||
@ -525,6 +574,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
disconnectAlertTask?.cancel()
|
||||
showCameraDisconnectedAlert = false
|
||||
pipeline.detachFromUI()
|
||||
otgCoordinator.detach()
|
||||
}
|
||||
|
||||
/// 刷新设备存储信息。
|
||||
@ -582,20 +632,98 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 刷新相机文件列表(OTG 能力已移除,仅刷新本地持久化记录)。
|
||||
/// 刷新相机文件列表;catalog 就绪时同步相机内已有照片(拍后传输模式)。
|
||||
func refreshCameraFiles() async {
|
||||
guard !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
|
||||
if connectionState.isConnected, isContentCatalogReady, let driver = otgCoordinator.currentDriver {
|
||||
do {
|
||||
let objects = try await driver.listObjects()
|
||||
for object in objects {
|
||||
guard !deletedPhotoIDs.contains(object.id) else { continue }
|
||||
guard !persistedRecordsByID.keys.contains(object.id) else { continue }
|
||||
pipeline.markDownloading(assetID: object.id, filename: object.filename)
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("camera-sync", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
|
||||
if let scope = downloadScope,
|
||||
let sink = makeTransientPhotoSink(scope: scope) {
|
||||
let saved = try await sink.saveDownloadedFile(
|
||||
at: downloadedURL,
|
||||
filename: object.filename,
|
||||
capturedAt: object.capturedAt,
|
||||
cameraObjectID: object.id
|
||||
)
|
||||
pipeline.ingestDownloadedAsset(
|
||||
assetID: saved.assetID,
|
||||
filename: saved.filename,
|
||||
localPath: saved.relativeLocalPath,
|
||||
capturedAt: saved.capturedAt,
|
||||
autoUpload: false
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loadPersistedPhotos()
|
||||
}
|
||||
|
||||
/// 重新搜索相机(OTG 能力已移除,保持未连接状态)。
|
||||
/// 重新搜索 USB 相机。
|
||||
func restartCameraSearch() async {
|
||||
guard !isSearchingCamera else { return }
|
||||
guard !connectionState.isConnected, !isSearchingCamera else { return }
|
||||
isSearchingCamera = true
|
||||
defer { isSearchingCamera = false }
|
||||
otgCoordinator.restartSearch()
|
||||
connectionState = otgCoordinator.connectionState
|
||||
}
|
||||
|
||||
/// 断开相机连接。
|
||||
func disconnectCamera() {
|
||||
otgCoordinator.disconnect()
|
||||
connectionState = .disconnected
|
||||
isContentCatalogReady = false
|
||||
wasConnected = false
|
||||
}
|
||||
|
||||
/// 打开历史导入页。
|
||||
func onHistoryImportClick() {
|
||||
guard connectionState.isConnected, isContentCatalogReady else {
|
||||
errorMessage = "请等待相机连接完成后再导入"
|
||||
return
|
||||
}
|
||||
showHistoryImport = true
|
||||
}
|
||||
|
||||
/// 创建历史导入 ViewModel。
|
||||
func makeHistoryImportViewModel(accountKey: String) -> CameraHistoryImportViewModel? {
|
||||
otgCoordinator.makeHistoryImportViewModel(
|
||||
albumId: context.albumId,
|
||||
accountKey: accountKey,
|
||||
onImported: { [weak self] in
|
||||
self?.loadPersistedPhotos()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTransientPhotoSink(scope: CameraDownloadStorage.Scope) -> TravelAlbumOTGPhotoSink? {
|
||||
TravelAlbumOTGPhotoSink(
|
||||
albumId: context.albumId,
|
||||
accountKey: scope.accountKey,
|
||||
pipeline: pipeline,
|
||||
existingObjectIDsProvider: { [weak self] in
|
||||
await self?.existingCameraObjectIDs() ?? []
|
||||
},
|
||||
autoUploadProvider: { [weak self] in
|
||||
self?.transferModeOption == Self.modeLiveCapture
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 历史导入照片网格单元格。
|
||||
struct CameraHistoryImportPhotoCell: View {
|
||||
let photo: CameraObject
|
||||
let isImported: Bool
|
||||
let isSelected: Bool
|
||||
let thumbnailProvider: () async -> Data?
|
||||
let onToggle: () -> Void
|
||||
|
||||
@State private var thumbnail: UIImage?
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Group {
|
||||
if let thumbnail {
|
||||
Image(uiImage: thumbnail)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Color.gray.opacity(0.15)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipped()
|
||||
|
||||
if isImported {
|
||||
Text("已导入")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.black.opacity(0.55))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.padding(6)
|
||||
} else {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(isSelected ? AppDesign.primary : .white)
|
||||
.shadow(radius: 2)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard !isImported else { return }
|
||||
onToggle()
|
||||
}
|
||||
.task(id: photo.id) {
|
||||
guard thumbnail == nil else { return }
|
||||
if let data = await thumbnailProvider(), let image = UIImage(data: data) {
|
||||
thumbnail = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 历史导入分组标题栏。
|
||||
struct CameraHistoryImportSectionHeader: View {
|
||||
let title: String
|
||||
let isFullySelected: Bool
|
||||
let hasImportable: Bool
|
||||
let onToggleAll: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(WiredTransferDesign.text333)
|
||||
Spacer()
|
||||
if hasImportable {
|
||||
Button(isFullySelected ? "取消全选" : "全选") {
|
||||
onToggleAll()
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(WiredTransferDesign.pageBackground)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 相机历史导入主页面。
|
||||
struct CameraHistoryImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel: CameraHistoryImportViewModel
|
||||
@State private var showSonyHelp = false
|
||||
|
||||
init(viewModel: CameraHistoryImportViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
content
|
||||
if case .importing = viewModel.state {
|
||||
importingOverlay
|
||||
}
|
||||
}
|
||||
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||
.navigationTitle("选择照片")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(importButtonTitle) {
|
||||
viewModel.importSelected()
|
||||
}
|
||||
.disabled(selectedCount == 0)
|
||||
}
|
||||
if viewModel.shouldShowSonyMTPHelp {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showSonyHelp = true
|
||||
} label: {
|
||||
Image(systemName: "questionmark.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { viewModel.loadPhotos() }
|
||||
.navigationDestination(isPresented: $showSonyHelp) {
|
||||
SonyMTPHelpView()
|
||||
}
|
||||
.onChange(of: viewModel.state) { state in
|
||||
if case .finished = state {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch viewModel.state {
|
||||
case .idle, .loading:
|
||||
ProgressView("正在加载相机照片…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .loaded, .importing:
|
||||
photoList(sections: viewModel.sections)
|
||||
case .finished:
|
||||
EmptyView()
|
||||
case .failed(let message):
|
||||
emptyView(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func photoList(sections: [CameraPhotoSection]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(sections) { section in
|
||||
Section {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 2) {
|
||||
ForEach(section.photos, id: \.id) { photo in
|
||||
CameraHistoryImportPhotoCell(
|
||||
photo: photo,
|
||||
isImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id),
|
||||
isSelected: viewModel.isPhotoSelected(id: photo.id),
|
||||
thumbnailProvider: { await viewModel.thumbnailData(for: photo, maxPixelSize: 160) },
|
||||
onToggle: { viewModel.togglePhoto(id: photo.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
CameraHistoryImportSectionHeader(
|
||||
title: section.title,
|
||||
isFullySelected: viewModel.isSectionFullySelected(section),
|
||||
hasImportable: viewModel.hasImportablePhotos(in: section),
|
||||
onToggleAll: { viewModel.toggleSection(section) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(message)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(WiredTransferDesign.text666)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
if viewModel.shouldShowSonyMTPHelp {
|
||||
Button("如何切换到 MTP 模式") {
|
||||
showSonyHelp = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var importingOverlay: some View {
|
||||
Group {
|
||||
if case .importing(let current, let total) = viewModel.state {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("正在导入 \(current)/\(total)")
|
||||
.font(.system(size: 15))
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedCount: Int {
|
||||
viewModel.selectedPhotoIDs.count
|
||||
}
|
||||
|
||||
private var importButtonTitle: String {
|
||||
"导入 (\(selectedCount))"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 索尼 MTP 模式帮助页。
|
||||
struct SonyMTPHelpView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(SonyMTPHelpContent.introduction)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(WiredTransferDesign.text333)
|
||||
|
||||
ForEach(SonyMTPHelpContent.sections) { section in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(section.title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(WiredTransferDesign.text333)
|
||||
Text(section.body)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(WiredTransferDesign.text666)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||
.navigationTitle(SonyMTPHelpContent.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,6 +77,7 @@ struct WiredCameraTransferView: View {
|
||||
WiredTransferBottomBar(
|
||||
selectUploadMode: viewModel.selectUploadMode,
|
||||
selectedCount: viewModel.selectedPhotoIDs.count,
|
||||
isCameraConnected: viewModel.isCameraConnected,
|
||||
onBatchUploadClick: {
|
||||
Task {
|
||||
await viewModel.onBatchUploadButtonClick(
|
||||
@ -86,7 +87,8 @@ struct WiredCameraTransferView: View {
|
||||
)
|
||||
}
|
||||
},
|
||||
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick
|
||||
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick,
|
||||
onHistoryImportClick: viewModel.onHistoryImportClick
|
||||
)
|
||||
}
|
||||
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||
@ -155,6 +157,13 @@ struct WiredCameraTransferView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $viewModel.showHistoryImport) {
|
||||
if let importVM = viewModel.makeHistoryImportViewModel(
|
||||
accountKey: accountContext.accountCachePrefix ?? "guest_"
|
||||
) {
|
||||
CameraHistoryImportView(viewModel: importVM)
|
||||
}
|
||||
}
|
||||
.sheet(item: previewBinding) { item in
|
||||
WiredTransferPhotoPreviewSheet(
|
||||
imageSources: item.sources,
|
||||
|
||||
@ -9,8 +9,10 @@ import SwiftUI
|
||||
struct WiredTransferBottomBar: View {
|
||||
let selectUploadMode: Bool
|
||||
let selectedCount: Int
|
||||
let isCameraConnected: Bool
|
||||
let onBatchUploadClick: () -> Void
|
||||
let onSpecifyUploadClick: () -> Void
|
||||
let onHistoryImportClick: () -> Void
|
||||
|
||||
private var batchButtonTitle: String {
|
||||
if !selectUploadMode { return "批量上传" }
|
||||
@ -33,6 +35,20 @@ struct WiredTransferBottomBar: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: onHistoryImportClick) {
|
||||
Text("历史导入")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(isCameraConnected && !selectUploadMode ? AppDesign.primary : WiredTransferDesign.text999)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: WiredTransferDesign.bottomButtonHeight)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius)
|
||||
.stroke(AppDesign.primary, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(selectUploadMode || !isCameraConnected)
|
||||
|
||||
Button(action: onSpecifyUploadClick) {
|
||||
Text("指定上传")
|
||||
.font(.system(size: 15))
|
||||
|
||||
Reference in New Issue
Block a user