Files
snail/Snail/Projects/ProjectFormView.swift
T
2026-10-29 13:14:46 +03:00

188 lines
6.7 KiB
Swift

//
// 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 }
}