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

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

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.

//
// WiredCameraTransferView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// 线
struct WiredCameraTransferView: View {
let context: WiredTransferContext
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
@Environment(\.ossUploadService) private var ossUploadService
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: WiredCameraTransferViewModel
@State private var previewSources: [String]?
@State private var previewStartIndex = 0
init(context: WiredTransferContext) {
self.context = context
_viewModel = StateObject(
wrappedValue: WiredCameraTransferViewModel(
context: context,
cameraService: CameraServiceFactory.make(brand: .sony)
)
)
}
private let tabTitles = ["全部", "已上传", "失败"]
private var scenicID: Int { accountContext.currentScenic?.id ?? 0 }
private var scenicSpotLabel: String? {
accountContext.currentScenic?.name
}
var body: some View {
VStack(spacing: 0) {
WiredTransferHeaderView(
albumTitle: context.albumName,
headerPhone: context.phone,
onBack: { dismiss() }
)
WiredTransferControlCard(
scenicSpotLabel: scenicSpotLabel,
deviceStorageInfo: viewModel.deviceStorageInfo,
isCameraConnected: viewModel.isCameraConnected,
isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles,
cameraDeviceName: viewModel.cameraDeviceName,
transferModeOption: viewModel.transferModeOption,
tabTitles: tabTitles,
tabCounts: viewModel.tabCounts,
selectedTabIndex: viewModel.selectedTabIndex,
onDeviceUsageClick: viewModel.onDeviceUsageClick,
onRefreshCameraFiles: {
Task { await viewModel.refreshCameraFiles() }
},
onHelpClick: viewModel.openHelpDoc,
onTransferModeSelected: { option in
viewModel.selectTransferMode(
option,
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: scenicID
)
},
onTabSelected: viewModel.selectTab
)
if viewModel.selectUploadMode, !visiblePhotos.isEmpty {
selectAllBar
}
contentSection
WiredTransferBottomBar(
selectUploadMode: viewModel.selectUploadMode,
selectedCount: viewModel.selectedPhotoIDs.count,
onBatchUploadClick: {
Task {
await viewModel.onBatchUploadButtonClick(
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: scenicID
)
}
},
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick
)
}
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.task {
await viewModel.start(
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: scenicID,
accountContext: accountContext
)
}
.onDisappear {
Task { await viewModel.stop() }
}
.onChange(of: viewModel.errorMessage) { message in
guard let message, !message.isEmpty else { return }
toastCenter.show(message)
viewModel.clearErrorMessage()
}
.onChange(of: viewModel.connectionState) { state in
switch state {
case .connected(let name):
toastCenter.show("已连接 \(name)")
case .error(let message):
toastCenter.show(message)
default:
break
}
}
.alert("手机内存不足", isPresented: $viewModel.showDeviceStorageAlert) {
Button("我知道了", role: .cancel) {}
} message: {
Text("请及时清理内存")
}
.alert("相机已断开", isPresented: $viewModel.showCameraDisconnectedAlert) {
Button("我知道了", role: .cancel) {}
} message: {
Text("相机已断开,请检查 USB 连接。")
}
.sheet(isPresented: $viewModel.showSpecifyUploadSheet) {
SpecifyUploadBottomSheet(isPresented: $viewModel.showSpecifyUploadSheet) { option in
Task {
await viewModel.onSpecifyUploadOptionSelected(
option,
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: scenicID
)
}
}
.presentationDetents([.height(340)])
.presentationDragIndicator(.hidden)
}
.sheet(isPresented: $viewModel.showHelpDoc) {
NavigationStack {
WiredTransferHelpDocView(url: WiredCameraTransferViewModel.helpDocURL)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { viewModel.showHelpDoc = false }
}
}
}
}
.sheet(item: previewBinding) { item in
WiredTransferPhotoPreviewSheet(
imageSources: item.sources,
startIndex: item.startIndex,
onDismiss: { previewSources = nil }
)
}
}
private var visiblePhotos: [WiredTransferPhotoItem] {
viewModel.visiblePhotos
}
private var sidebarGroups: [WiredTransferDateGroup] {
viewModel.sidebarGroups
}
private var photoSections: [WiredTransferPhotoSection] {
viewModel.photoSections
}
private var selectAllBar: some View {
let pendingIDs = visiblePhotos.selectablePendingPhotoIDs
let allSelected = !pendingIDs.isEmpty && pendingIDs.isSubset(of: viewModel.selectedPhotoIDs)
return WiredTransferSelectAllBar(
selectedCount: viewModel.selectedPhotoIDs.count,
selectableCount: pendingIDs.count,
allSelected: allSelected,
onToggleSelectAll: viewModel.toggleSelectAllPendingInVisible
)
}
@ViewBuilder
private var contentSection: some View {
if visiblePhotos.isEmpty {
WiredTransferEmptyState()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
WiredTransferPhotoListPanel(
sidebarExpanded: viewModel.sidebarExpanded,
sidebarGroups: sidebarGroups,
photoSections: photoSections,
selectedTimeSlotID: viewModel.selectedTimeSlotID,
selectUploadMode: viewModel.selectUploadMode,
selectedPhotoIDs: viewModel.selectedPhotoIDs,
onToggleSidebar: viewModel.toggleSidebar,
onTimeSlotSelected: viewModel.selectTimeSlot,
onPhotoClick: openPhotoPreview,
onRetry: { id in
Task {
await viewModel.retryPhoto(
id: id,
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: scenicID
)
}
},
onDelete: viewModel.deletePhoto,
onTogglePhotoSelection: viewModel.togglePhotoSelection
)
}
}
private var previewBinding: Binding<PreviewPayload?> {
Binding(
get: {
guard let previewSources else { return nil }
return PreviewPayload(sources: previewSources, startIndex: previewStartIndex)
},
set: { newValue in
if newValue == nil { previewSources = nil }
}
)
}
private func openPhotoPreview(photoID: String) {
let entries = visiblePhotos.compactMap { photo -> String? in
let url = viewModel.previewURL(for: photo.id)
return url.isEmpty ? nil : url
}
guard let startIndex = visiblePhotos.firstIndex(where: { $0.id == photoID }),
!viewModel.previewURL(for: photoID).isEmpty else {
toastCenter.show("暂无法预览该照片")
return
}
let mappedIndex = visiblePhotos.prefix(startIndex + 1).filter {
!viewModel.previewURL(for: $0.id).isEmpty
}.count - 1
previewStartIndex = max(mappedIndex, 0)
previewSources = entries
}
}
/// Sheet
private struct PreviewPayload: Identifiable {
let id = UUID()
let sources: [String]
let startIndex: Int
}