Files
suixinkan_ios_new/suixinkan/Features/Tasks/Views/TaskCreateView.swift
汉秋 26f4d0e671 Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

282 lines
12 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.

//
// TaskCreateView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
///
struct TaskCreateView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.taskAPI) private var taskAPI
@Environment(\.ossUploadService) private var ossUploadService
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = TaskCreateViewModel()
@State private var showOrderSelection = false
@State private var showCloudSelection = false
@State private var pickerItems: [PhotosPickerItem] = []
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
basicFormSection
orderSection
cloudFileSection
localFileSection
submitButton
}
.padding(AppMetrics.Spacing.pageHorizontal)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("发布任务")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadAvailableOrders(api: taskAPI, scenicId: accountContext.currentScenic?.id)
}
.sheet(isPresented: $showOrderSelection) {
NavigationStack {
TaskOrderSelectionView(
orders: viewModel.availableOrders,
selectedOrder: viewModel.selectedOrder,
onSelect: { order in
viewModel.selectOrder(order)
showOrderSelection = false
}
)
}
}
.sheet(isPresented: $showCloudSelection) {
NavigationStack {
TaskCloudFileSelectionView { items in
viewModel.mergeCloudFiles(items)
showCloudSelection = false
}
}
}
.onChange(of: pickerItems) { items in
Task { await importPickerItems(items) }
}
}
///
private var basicFormSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("任务信息")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
TextField("任务名称", text: $viewModel.taskName)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("紧急小时", text: $viewModel.urgentHourText)
.keyboardType(.numberPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("任务备注", text: $viewModel.remark, axis: .vertical)
.lineLimit(4, reservesSpace: true)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 104)
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var orderSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text("关联订单")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Button(viewModel.selectedOrder == nil ? "选择订单" : "更换") {
showOrderSelection = true
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
if let order = viewModel.selectedOrder {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text(order.projectName.isEmpty ? "未命名项目" : order.projectName)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("订单号:\(order.orderNumber)")
Text("手机号:\(order.userPhone.isEmpty ? "暂无" : order.userPhone)")
}
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
Button("清除关联订单") {
viewModel.selectOrder(nil)
}
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(Color(hex: 0xEF4444))
} else {
Text("可不选择订单,直接发布普通任务。")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var cloudFileSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text("云盘附件")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Button("选择云盘文件") {
showCloudSelection = true
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
if viewModel.selectedCloudFiles.isEmpty {
Text("未选择云盘文件")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.selectedCloudFiles) { file in
TaskAttachmentRow(title: file.fileName, subtitle: "云盘文件") {
viewModel.removeCloudFile(id: file.id)
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var localFileSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text("本地附件")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
PhotosPicker(selection: $pickerItems, maxSelectionCount: 9, matching: .any(of: [.images, .videos])) {
Text("选择图片/视频")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
}
if viewModel.selectedLocalFiles.isEmpty {
Text("未选择本地文件")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.selectedLocalFiles) { file in
TaskAttachmentRow(title: file.fileName, subtitle: file.statusText) {
viewModel.removeLocalFile(id: file.id)
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var submitButton: some View {
Button {
Task { await submit() }
} label: {
Text(viewModel.isSubmitting ? "提交中..." : "提交任务")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
}
.disabled(viewModel.isSubmitting)
.opacity(viewModel.isSubmitting ? 0.6 : 1)
}
/// PhotosPicker
private func importPickerItems(_ items: [PhotosPickerItem]) async {
for item in items {
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
let fileName = Self.fileName(for: item)
viewModel.addLocalFile(data: data, fileName: fileName)
} catch {
toastCenter.show(error.localizedDescription)
}
}
pickerItems = []
}
///
private func submit() async {
do {
let success = try await globalLoading.withLoading {
try await viewModel.submit(api: taskAPI, uploadService: ossUploadService, scenicId: accountContext.currentScenic?.id)
}
if success {
toastCenter.show("任务发布成功")
dismiss()
} else if let message = viewModel.errorMessage {
toastCenter.show(message)
}
} catch {
toastCenter.show(viewModel.errorMessage ?? error.localizedDescription)
}
}
///
private static func fileName(for item: PhotosPickerItem) -> String {
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) || $0.conforms(to: .video) }
return "\(UUID().uuidString).\(isVideo ? "mp4" : "jpg")"
}
}
///
private struct TaskAttachmentRow: View {
let title: String
let subtitle: String
let onDelete: () -> Void
var body: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "paperclip")
.foregroundStyle(AppDesign.primary)
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
.background(AppDesign.primarySoft, in: Circle())
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Text(subtitle)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppDesign.placeholder)
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
}
.accessibilityLabel("删除附件")
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
}