krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

main: Hutch/Views/Projects/ProjectFormSheet.swift · raw

  1import SwiftUI
  2
  3/// Shared create/edit form for hub.sr.ht projects. The parent owns the save
  4/// state and performs the mutation via `onSave`, dismissing on success.
  5struct ProjectFormSheet: View {
  6    let title: String
  7    let confirmationTitle: String
  8    let isSaving: Bool
  9    let error: String?
 10    /// `createProject` takes no website; only the edit flow shows the field.
 11    let includeWebsite: Bool
 12    let onSave: (ProjectFormValues) async -> Bool
 13
 14    @Environment(\.dismiss) private var dismiss
 15    @State private var name: String
 16    @State private var description: String
 17    @State private var website: String
 18    @State private var tags: String
 19    @State private var visibility: Visibility
 20
 21    init(
 22        title: String,
 23        confirmationTitle: String,
 24        isSaving: Bool,
 25        error: String?,
 26        includeWebsite: Bool,
 27        initialName: String = "",
 28        initialDescription: String = "",
 29        initialWebsite: String = "",
 30        initialTags: [String] = [],
 31        initialVisibility: Visibility = .publicVisibility,
 32        onSave: @escaping (ProjectFormValues) async -> Bool
 33    ) {
 34        self.title = title
 35        self.confirmationTitle = confirmationTitle
 36        self.isSaving = isSaving
 37        self.error = error
 38        self.includeWebsite = includeWebsite
 39        self.onSave = onSave
 40        _name = State(initialValue: initialName)
 41        _description = State(initialValue: initialDescription)
 42        _website = State(initialValue: initialWebsite)
 43        _tags = State(initialValue: initialTags.joined(separator: ", "))
 44        _visibility = State(initialValue: initialVisibility)
 45    }
 46
 47    private var trimmedName: String {
 48        name.trimmingCharacters(in: .whitespacesAndNewlines)
 49    }
 50
 51    var body: some View {
 52        NavigationStack {
 53            Form {
 54                Section("Project Details") {
 55                    TextField("Project name", text: $name)
 56                        .textInputAutocapitalization(.never)
 57                        .autocorrectionDisabled()
 58                        .themedRow()
 59                    TextField("Description (optional)", text: $description, axis: .vertical)
 60                        .lineLimit(2...4)
 61                        .themedRow()
 62                    if includeWebsite {
 63                        TextField("Website (optional)", text: $website)
 64                            .textInputAutocapitalization(.never)
 65                            .autocorrectionDisabled()
 66                            .keyboardType(.URL)
 67                            .themedRow()
 68                    }
 69                    Picker("Visibility", selection: $visibility) {
 70                        Text("Public").tag(Visibility.publicVisibility)
 71                        Text("Unlisted").tag(Visibility.unlisted)
 72                        Text("Private").tag(Visibility.privateVisibility)
 73                    }
 74                    .themedRow()
 75                }
 76
 77                Section {
 78                    TextField("Tags (comma separated)", text: $tags)
 79                        .textInputAutocapitalization(.never)
 80                        .autocorrectionDisabled()
 81                        .themedRow()
 82                } footer: {
 83                    Text("Separate tags with commas.")
 84                }
 85
 86                if let error, !error.isEmpty {
 87                    Section {
 88                        Text(error)
 89                            .foregroundStyle(.red)
 90                            .themedRow()
 91                    }
 92                }
 93            }
 94            .themedList()
 95            .navigationTitle(title)
 96            .navigationBarTitleDisplayMode(.inline)
 97            .toolbar {
 98                ToolbarItem(placement: .cancellationAction) {
 99                    Button("Cancel") { dismiss() }
100                }
101                ToolbarItem(placement: .confirmationAction) {
102                    Button {
103                        Task {
104                            let values = ProjectFormValues(
105                                name: trimmedName,
106                                description: description.trimmingCharacters(in: .whitespacesAndNewlines),
107                                website: website.trimmingCharacters(in: .whitespacesAndNewlines),
108                                visibility: visibility,
109                                tags: Self.parseTags(tags)
110                            )
111                            if await onSave(values) {
112                                dismiss()
113                            }
114                        }
115                    } label: {
116                        if isSaving {
117                            ProgressView().controlSize(.small)
118                        } else {
119                            Text(confirmationTitle)
120                        }
121                    }
122                    .disabled(trimmedName.isEmpty || isSaving)
123                }
124            }
125        }
126    }
127
128    static func parseTags(_ raw: String) -> [String] {
129        var seen = Set<String>()
130        return raw
131            .split(whereSeparator: { $0 == "," || $0.isNewline })
132            .map { $0.trimmingCharacters(in: .whitespaces) }
133            .filter { !$0.isEmpty }
134            .filter { seen.insert($0.lowercased()).inserted }
135    }
136}
137
138struct ProjectFormValues: Sendable {
139    let name: String
140    let description: String
141    let website: String
142    let visibility: Visibility
143    let tags: [String]
144}