Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
302 lines
11 KiB
Swift
302 lines
11 KiB
Swift
//
|
||
// WiredCameraTransferView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/29.
|
||
//
|
||
|
||
import SwiftUI
|
||
import UIKit
|
||
|
||
/// 有线传图页,连接相机并上传照片到旅拍相册。
|
||
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
|
||
|
||
@StateObject private var viewModel: WiredCameraTransferViewModel
|
||
|
||
init(context: WiredTransferContext) {
|
||
self.context = context
|
||
_viewModel = StateObject(
|
||
wrappedValue: WiredCameraTransferViewModel(
|
||
context: context,
|
||
cameraService: CameraServiceFactory.make(brand: .sony)
|
||
)
|
||
)
|
||
}
|
||
|
||
private let tabTitles = ["全部", "已上传", "失败"]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
headerSection
|
||
tabSection
|
||
photoListSection
|
||
bottomBar
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("有线传图")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task {
|
||
await viewModel.start(
|
||
api: travelAlbumAPI,
|
||
ossService: ossUploadService,
|
||
scenicID: accountContext.currentScenic?.id ?? 0,
|
||
accountContext: accountContext
|
||
)
|
||
}
|
||
.onDisappear {
|
||
Task { await viewModel.stop() }
|
||
}
|
||
.onChange(of: viewModel.errorMessage) { message in
|
||
guard let message, !message.isEmpty else { return }
|
||
toastCenter.show(message)
|
||
}
|
||
.onChange(of: viewModel.connectionState) { state in
|
||
switch state {
|
||
case .connected(let name):
|
||
toastCenter.show("已连接 \(name)")
|
||
case .disconnected:
|
||
break
|
||
case .error(let message):
|
||
toastCenter.show(message)
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
.confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) {
|
||
Button("上传全部待上传") {
|
||
Task { await viewModel.batchUploadAll() }
|
||
}
|
||
Button("上传选中项") {
|
||
Task { await viewModel.uploadSelected() }
|
||
}
|
||
Button("取消", role: .cancel) {}
|
||
}
|
||
}
|
||
|
||
private var headerSection: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(context.albumName)
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||
if !context.phone.isEmpty {
|
||
Text(context.phone)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
connectionBadge
|
||
}
|
||
|
||
Picker("传输模式", selection: Binding(
|
||
get: { viewModel.transferModeOption },
|
||
set: { newValue in
|
||
viewModel.selectTransferMode(
|
||
newValue,
|
||
api: travelAlbumAPI,
|
||
ossService: ossUploadService,
|
||
scenicID: accountContext.currentScenic?.id ?? 0
|
||
)
|
||
}
|
||
)) {
|
||
Text(WiredCameraTransferViewModel.modeLiveCapture).tag(WiredCameraTransferViewModel.modeLiveCapture)
|
||
Text(WiredCameraTransferViewModel.modePostShootTransfer).tag(WiredCameraTransferViewModel.modePostShootTransfer)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
.background(Color.white)
|
||
}
|
||
|
||
private var connectionBadge: some View {
|
||
VStack(spacing: 4) {
|
||
Circle()
|
||
.stroke(connectionColor, lineWidth: 3)
|
||
.frame(width: 44, height: 44)
|
||
.overlay {
|
||
Image(systemName: "camera.fill")
|
||
.foregroundStyle(connectionColor)
|
||
}
|
||
Text(connectionText)
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.multilineTextAlignment(.center)
|
||
.frame(width: 72)
|
||
}
|
||
}
|
||
|
||
private var connectionColor: Color {
|
||
switch viewModel.connectionState {
|
||
case .connected: AppDesign.success
|
||
case .error: Color.red
|
||
case .searching, .connecting: AppDesign.warning
|
||
case .disconnected: AppDesign.placeholder
|
||
}
|
||
}
|
||
|
||
private var connectionText: String {
|
||
switch viewModel.connectionState {
|
||
case .connected(let name):
|
||
name
|
||
default:
|
||
viewModel.connectionState.displayText
|
||
}
|
||
}
|
||
|
||
private var tabSection: some View {
|
||
let counts = viewModel.photos.transferTabCounts
|
||
return HStack(spacing: 0) {
|
||
ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in
|
||
Button {
|
||
viewModel.selectedTabIndex = index
|
||
} label: {
|
||
VStack(spacing: 4) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedTabIndex == index ? .semibold : .regular))
|
||
Text("\(counts[index])")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
}
|
||
.foregroundStyle(viewModel.selectedTabIndex == index ? AppDesign.primary : AppDesign.textSecondary)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 10)
|
||
.background(viewModel.selectedTabIndex == index ? AppDesign.primarySoft : Color.clear)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.background(Color.white)
|
||
}
|
||
|
||
private var photoListSection: some View {
|
||
let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex)
|
||
return ScrollView {
|
||
if visiblePhotos.isEmpty {
|
||
AppContentUnavailableView("暂无照片", systemImage: "photo")
|
||
.frame(maxWidth: .infinity, minHeight: 260)
|
||
} else {
|
||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||
ForEach(visiblePhotos) { photo in
|
||
photoRow(photo)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func photoRow(_ photo: WiredTransferPhotoItem) -> some View {
|
||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||
if viewModel.selectUploadMode, photo.canSelectForSpecifyUpload {
|
||
Button {
|
||
viewModel.togglePhotoSelection(id: photo.id)
|
||
} label: {
|
||
Image(systemName: viewModel.selectedPhotoIDs.contains(photo.id) ? "checkmark.circle.fill" : "circle")
|
||
.foregroundStyle(viewModel.selectedPhotoIDs.contains(photo.id) ? AppDesign.primary : AppDesign.placeholder)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
thumbnailView(for: photo)
|
||
.frame(width: 64, height: 64)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(photo.fileName)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||
.lineLimit(1)
|
||
Text(photo.capturedAt)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
HStack {
|
||
Text(photo.status.displayLabel)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(statusColor(photo.status))
|
||
if photo.status == .uploading || photo.status == .transferring {
|
||
Text("\(photo.progress)%")
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if photo.status == .failed {
|
||
Button("重试") {
|
||
Task { await viewModel.retryPhoto(id: photo.id) }
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(Color.white)
|
||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func thumbnailView(for photo: WiredTransferPhotoItem) -> some View {
|
||
if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) {
|
||
if let uiImage = UIImage(contentsOfFile: url.path) {
|
||
Image(uiImage: uiImage)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} else {
|
||
placeholderThumbnail
|
||
}
|
||
} else {
|
||
RemoteImage(urlString: photo.thumbnailURL) {
|
||
placeholderThumbnail
|
||
}
|
||
}
|
||
}
|
||
|
||
private var placeholderThumbnail: some View {
|
||
Color(hex: 0xE5E7EB)
|
||
.overlay {
|
||
Image(systemName: "photo")
|
||
.foregroundStyle(AppDesign.placeholder)
|
||
}
|
||
}
|
||
|
||
private func statusColor(_ status: WiredTransferUploadStatus) -> Color {
|
||
switch status {
|
||
case .uploaded: AppDesign.success
|
||
case .failed: Color.red
|
||
case .uploading, .transferring: AppDesign.warning
|
||
case .pending: AppDesign.textSecondary
|
||
}
|
||
}
|
||
|
||
private var bottomBar: some View {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
Button("同步历史") {
|
||
Task { await viewModel.syncExistingPhotos() }
|
||
}
|
||
.buttonStyle(.bordered)
|
||
|
||
Button("批量上传") {
|
||
Task { await viewModel.batchUploadAll() }
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
Button("指定上传") {
|
||
viewModel.selectUploadMode = true
|
||
viewModel.showSpecifyUploadSheet = true
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
.background(Color.white)
|
||
}
|
||
}
|