Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift
汉秋 28db8b3182 优化相册管理与指定上传弹层展示逻辑。
相册详情网格仅对已购买素材显示角标;指定上传 Sheet 改用 NavigationStack 与系统 navigationTitle。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 14:00:46 +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(360)])
.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
}