// // ProjectFormView.swift // Snail // import SwiftUI enum ProjectFormMode: Identifiable { case create case edit(Project) var id: String { switch self { case .create: "create" case .edit(let project): project.id } } } /// Форма создания и изменения проекта. Ключ задаётся только при создании. struct ProjectFormView: View { @Environment(\.dismiss) private var dismiss let mode: ProjectFormMode let onSubmit: (_ key: String, _ name: String) async throws -> Void /// Ограничения совпадают с серверными (ProjectName.MAX_LENGTH) и форматом номеров задач KEY-1. private static let maxNameLength = 100 private static let maxKeyLength = 10 private enum Field { case name case key } @State private var name: String @State private var key: String @State private var isSaving = false @State private var errorMessage: String? @FocusState private var focusedField: Field? init(mode: ProjectFormMode, onSubmit: @escaping (_ key: String, _ name: String) async throws -> Void) { self.mode = mode self.onSubmit = onSubmit switch mode { case .create: _name = State(initialValue: "") _key = State(initialValue: "") case .edit(let project): _name = State(initialValue: project.name) _key = State(initialValue: project.key) } } private var isCreating: Bool { if case .create = mode { true } else { false } } private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } private var isKeyValid: Bool { key.wholeMatch(of: /[A-Z][A-Z0-9]{1,9}/) != nil } private var isNameValid: Bool { !trimmedName.isEmpty && trimmedName.count <= Self.maxNameLength } private var hasChanges: Bool { switch mode { case .create: !trimmedName.isEmpty || !key.isEmpty case .edit(let project): trimmedName != project.name } } private var canSubmit: Bool { isNameValid && (isCreating ? isKeyValid : hasChanges) && !isSaving } var body: some View { NavigationStack { Form { Section { TextField("Название", text: $name, prompt: Text("Например, Мобильное приложение")) .textInputAutocapitalization(.sentences) .submitLabel(isCreating ? .next : .done) .focused($focusedField, equals: .name) .onSubmit { if isCreating { focusedField = .key } else { submit() } } } header: { Text("Название") } footer: { if trimmedName.count > Self.maxNameLength { Text("Не больше \(Self.maxNameLength) символов.") .foregroundStyle(.red) } } Section { if isCreating { TextField("Ключ", text: $key, prompt: Text(verbatim: "APP")) .font(.body.monospaced()) .textInputAutocapitalization(.characters) .autocorrectionDisabled() .keyboardType(.asciiCapable) .submitLabel(.done) .focused($focusedField, equals: .key) .onSubmit(submit) .onChange(of: key) { _, newValue in let normalized = String( newValue.uppercased() .filter { ("A"..."Z").contains($0) || ("0"..."9").contains($0) } .prefix(Self.maxKeyLength) ) if normalized != newValue { key = normalized } } } else { LabeledContent("Ключ") { Text(key).monospaced() } } } header: { Text("Ключ") } footer: { if isCreating { Text("От 2 до \(Self.maxKeyLength) латинских букв и цифр, начинается с буквы. Используется в номерах задач — \(key.isEmpty ? "APP" : key)-1. Изменить ключ потом нельзя.") } else { Text("Ключ используется в номерах задач и не меняется.") } } if let errorMessage { Section { Label(errorMessage, systemImage: "exclamationmark.circle") .foregroundStyle(.red) } } } .navigationTitle(isCreating ? "Новый проект" : "Изменить проект") .navigationBarTitleDisplayMode(.inline) .disabled(isSaving) .interactiveDismissDisabled(hasChanges || isSaving) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена", role: .cancel) { dismiss() } .disabled(isSaving) } ToolbarItem(placement: .confirmationAction) { if isSaving { ProgressView() } else { Button(isCreating ? "Создать" : "Сохранить", action: submit) .disabled(!canSubmit) } } } .onAppear { focusedField = .name } } } private func submit() { guard canSubmit else { return } isSaving = true errorMessage = nil Task { defer { isSaving = false } do { try await onSubmit(key, trimmedName) dismiss() } catch { errorMessage = error.localizedDescription AccessibilityNotification.Announcement(error.localizedDescription).post() } } } } #Preview("Создание") { ProjectFormView(mode: .create) { _, _ in } }