引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
8.5 KiB
Swift
241 lines
8.5 KiB
Swift
//
|
||
// WiredTransferControlCard.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 有线传图浮动控制卡片:连接区 + 筛选 + Tab。
|
||
struct WiredTransferControlCard: View {
|
||
let scenicSpotLabel: String?
|
||
let deviceStorageInfo: DeviceStorageSnapshot
|
||
let isCameraConnected: Bool
|
||
let isRefreshingCameraFiles: Bool
|
||
let isSearchingCamera: Bool
|
||
let cameraDeviceName: String
|
||
let transferModeOption: String
|
||
let tabTitles: [String]
|
||
let tabCounts: [Int]
|
||
let selectedTabIndex: Int
|
||
let onDeviceUsageClick: () -> Void
|
||
let onRefreshCameraFiles: () -> Void
|
||
let onHelpClick: () -> Void
|
||
let onTransferModeSelected: (String) -> Void
|
||
let onTabSelected: (Int) -> Void
|
||
|
||
private let modeOptions = [
|
||
WiredCameraTransferViewModel.modeLiveCapture,
|
||
WiredCameraTransferViewModel.modePostShootTransfer
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
VStack(spacing: 12) {
|
||
connectionStatusRow
|
||
filterChipRow
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 14)
|
||
|
||
Divider().background(WiredTransferDesign.borderLight)
|
||
|
||
WiredTransferStatsTabRow(
|
||
tabTitles: tabTitles,
|
||
counts: tabCounts,
|
||
selectedTabIndex: selectedTabIndex,
|
||
onTabSelected: onTabSelected
|
||
)
|
||
}
|
||
.background(Color.white)
|
||
.clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.cardCornerRadius))
|
||
.shadow(color: WiredTransferDesign.cardShadow, radius: 6, x: 0, y: 2)
|
||
.padding(.horizontal, WiredTransferDesign.cardHorizontalPadding)
|
||
.offset(y: -WiredTransferDesign.cardOverlap)
|
||
}
|
||
|
||
private var connectionStatusRow: some View {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
deviceUsagePanel
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
connectionChip
|
||
Spacer(minLength: 4)
|
||
refreshButton
|
||
}
|
||
if !isCameraConnected {
|
||
helpDocLine
|
||
}
|
||
if let scenicSpotLabel, !scenicSpotLabel.isEmpty {
|
||
Text(scenicSpotLabel)
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(WiredTransferDesign.text999)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var deviceUsagePanel: some View {
|
||
Button(action: onDeviceUsageClick) {
|
||
VStack(spacing: 6) {
|
||
Text(deviceStorageInfo.modelName)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(WiredTransferDesign.text333)
|
||
.multilineTextAlignment(.center)
|
||
.lineLimit(2)
|
||
.frame(maxWidth: 88)
|
||
Text(deviceStorageInfo.availableGbText)
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(AppDesign.primary)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(minWidth: WiredTransferDesign.statusRingMinWidth)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 10)
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(WiredTransferDesign.borderLight, lineWidth: 3)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private var connectionChip: some View {
|
||
Text(connectionChipText)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(isCameraConnected ? AppDesign.primary : WiredTransferDesign.danger)
|
||
.lineLimit(1)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 2)
|
||
.background((isCameraConnected ? AppDesign.primary : WiredTransferDesign.danger).opacity(0.1))
|
||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
|
||
private var connectionChipText: String {
|
||
if isCameraConnected {
|
||
let name = cameraDeviceName.isEmpty ? "已连接相机" : cameraDeviceName
|
||
return name
|
||
}
|
||
return "未连接相机"
|
||
}
|
||
|
||
private var refreshButton: some View {
|
||
Button(action: onRefreshCameraFiles) {
|
||
Text(refreshButtonTitle)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 10)
|
||
.frame(minWidth: 64)
|
||
.frame(height: 34)
|
||
.background(refreshButtonColor)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isRefreshingCameraFiles || isSearchingCamera)
|
||
.opacity(isRefreshingCameraFiles || isSearchingCamera ? 0.85 : 1)
|
||
}
|
||
|
||
private var refreshButtonTitle: String {
|
||
if isSearchingCamera { return "搜索中" }
|
||
if isRefreshingCameraFiles { return "读取中" }
|
||
if isCameraConnected { return "刷新" }
|
||
return "重新搜索"
|
||
}
|
||
|
||
private var refreshButtonColor: Color {
|
||
if isSearchingCamera || isRefreshingCameraFiles { return WiredTransferDesign.text999 }
|
||
if !isCameraConnected { return AppDesign.primary }
|
||
return AppDesign.primary
|
||
}
|
||
|
||
private var helpDocLine: some View {
|
||
Button(action: onHelpClick) {
|
||
HStack(spacing: 0) {
|
||
Text("未连接,查看")
|
||
.foregroundStyle(WiredTransferDesign.text666)
|
||
Text("《帮助文档》")
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
.font(.system(size: 10))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private var filterChipRow: some View {
|
||
GeometryReader { geometry in
|
||
let totalWeight: CGFloat = 1 + 0.72 + 1.15
|
||
let spacing: CGFloat = 6
|
||
let available = geometry.size.width - spacing * 2
|
||
HStack(spacing: spacing) {
|
||
WiredTransferFilterChip(label: "不修图", options: [], enabled: false, onSelected: { _ in })
|
||
.frame(width: available * 1 / totalWeight)
|
||
WiredTransferFilterChip(
|
||
label: WiredCameraTransferViewModel.formatJPG,
|
||
options: [],
|
||
enabled: false,
|
||
onSelected: { _ in }
|
||
)
|
||
.frame(width: available * 0.72 / totalWeight)
|
||
WiredTransferFilterChip(
|
||
label: transferModeOption,
|
||
options: modeOptions,
|
||
onSelected: onTransferModeSelected
|
||
)
|
||
.frame(width: available * 1.15 / totalWeight)
|
||
}
|
||
}
|
||
.frame(height: WiredTransferDesign.filterChipHeight)
|
||
}
|
||
}
|
||
|
||
/// 统计 Tab 行,对齐 Android TransferStatsTabRow。
|
||
struct WiredTransferStatsTabRow: View {
|
||
let tabTitles: [String]
|
||
let counts: [Int]
|
||
let selectedTabIndex: Int
|
||
let onTabSelected: (Int) -> Void
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in
|
||
if index > 0 {
|
||
Divider()
|
||
.frame(height: 32)
|
||
.background(WiredTransferDesign.borderLight)
|
||
}
|
||
tabItem(index: index, title: title)
|
||
}
|
||
}
|
||
.frame(height: WiredTransferDesign.tabRowHeight)
|
||
}
|
||
|
||
private func tabItem(index: Int, title: String) -> some View {
|
||
Button {
|
||
onTabSelected(index)
|
||
} label: {
|
||
VStack(spacing: 2) {
|
||
Text("\(counts.indices.contains(index) ? counts[index] : 0)")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(countColor(for: index))
|
||
Text(title)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(selectedTabIndex == index ? AppDesign.primary : WiredTransferDesign.text666)
|
||
RoundedRectangle(cornerRadius: 1)
|
||
.fill(selectedTabIndex == index ? AppDesign.primary : Color.clear)
|
||
.frame(width: 24, height: 2)
|
||
.padding(.top, 2)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func countColor(for index: Int) -> Color {
|
||
if selectedTabIndex == index { return AppDesign.primary }
|
||
if index == 2 { return WiredTransferDesign.danger }
|
||
return WiredTransferDesign.text333
|
||
}
|
||
}
|