gitbay/Views/Snippets/SnippetListView.swift

v1.6.2
gitbay-ios/gitbay/Views/Snippets/SnippetListView.swift history · blame · raw

196 lines · 7203 bytes

  1import SwiftUI
  2
  3/// Your snippets at every visibility, or an owner's public ones.
  4struct SnippetListView: View {
  5
  6    @State private var model: SnippetListViewModel
  7    @State private var composing = false
  8
  9    init(client: GitbayClient, owner: String?) {
 10        _model = State(initialValue: SnippetListViewModel(client: client, owner: owner))
 11    }
 12
 13    var body: some View {
 14        List {
 15            ForEach(model.state.value ?? []) { snippet in
 16                NavigationLink(value: SnippetRoute.snippet(id: snippet.id)) {
 17                    SnippetRow(snippet: snippet, showsVisibility: model.owner == nil)
 18                }
 19            }
 20            PageFooter(list: model.list)
 21        }
 22        .overlay { LoadStateOverlay(state: model.state) }
 23        .navigationTitle(model.owner.map { "\($0)'s snippets" } ?? "Snippets")
 24        .navigationBarTitleDisplayMode(.inline)
 25        .toolbar {
 26            if model.owner == nil {
 27                ToolbarItem(placement: .topBarTrailing) {
 28                    Button {
 29                        composing = true
 30                    } label: {
 31                        Image(systemName: "plus")
 32                    }
 33                    .accessibilityIdentifier("snippet-create-button")
 34                }
 35            }
 36        }
 37        .sheet(isPresented: $composing) {
 38            SnippetFileSheet(
 39                heading: "New Snippet",
 40                submitLabel: "Create",
 41                fileName: "",
 42                showsSettings: true,
 43                working: model.working,
 44                errorMessage: model.actionError
 45            ) { name, description, visibility, content in
 46                Task {
 47                    if await model.create(
 48                        filename: name, description: description,
 49                        visibility: visibility, content: content) != nil {
 50                        composing = false
 51                    }
 52                }
 53            }
 54        }
 55        .task { await model.load() }
 56        .refreshable { await model.load() }
 57    }
 58}
 59
 60struct SnippetRow: View {
 61    let snippet: Snippet
 62    let showsVisibility: Bool
 63
 64    var body: some View {
 65        VStack(alignment: .leading, spacing: 3) {
 66            HStack(spacing: 6) {
 67                Text(snippet.title)
 68                    .font(.gbSans(.subheadline).weight(.medium))
 69                    .lineLimit(1)
 70                if showsVisibility, snippet.visibility != "public" {
 71                    GBChip(snippet.visibility, .secondary)
 72                }
 73            }
 74            HStack(spacing: 6) {
 75                Text(snippet.fileNames)
 76                    .font(.gbMono(.caption))
 77                    .lineLimit(1)
 78                Spacer()
 79                Text(snippet.updatedAt, format: .relative(presentation: .named))
 80            }
 81            .font(.gbSans(.caption))
 82            .foregroundStyle(.secondary)
 83        }
 84        .padding(.vertical, 2)
 85    }
 86}
 87
 88/// A file's name and content, with the description and visibility when
 89/// creating. Also used to add a file, or to replace one's content, in
 90/// which case the name is fixed.
 91struct SnippetFileSheet: View {
 92
 93    let heading: String
 94    let submitLabel: String
 95    let fileName: String
 96    let showsSettings: Bool
 97    let working: Bool
 98    let errorMessage: String?
 99    /// name, description, visibility, content.
100    let onSubmit: (String, String, String, String) -> Void
101
102    @Environment(\.dismiss) private var dismiss
103    @State private var name: String
104    @State private var description = ""
105    @State private var visibility = "unlisted"
106    @State private var content: String
107
108    init(heading: String, submitLabel: String, fileName: String, showsSettings: Bool,
109         initialContent: String = "", working: Bool, errorMessage: String?,
110         onSubmit: @escaping (String, String, String, String) -> Void) {
111        self.heading = heading
112        self.submitLabel = submitLabel
113        self.fileName = fileName
114        self.showsSettings = showsSettings
115        self.working = working
116        self.errorMessage = errorMessage
117        self.onSubmit = onSubmit
118        _name = State(initialValue: fileName)
119        _content = State(initialValue: initialContent)
120    }
121
122    private var nameFixed: Bool { !fileName.isEmpty }
123
124    var body: some View {
125        NavigationStack {
126            Form {
127                Section {
128                    if nameFixed {
129                        Text(fileName)
130                            .font(.gbMono(.subheadline))
131                    } else {
132                        TextField("File name", text: $name)
133                            .font(.gbMono(.subheadline))
134                            .autocorrectionDisabled()
135                            .textInputAutocapitalization(.never)
136                            .accessibilityIdentifier("snippet-file-name")
137                    }
138                    if showsSettings {
139                        TextField("Description (optional)", text: $description)
140                            .autocorrectionDisabled()
141                            .accessibilityIdentifier("snippet-description")
142                        Picker("Visibility", selection: $visibility) {
143                            Text("unlisted").tag("unlisted")
144                            Text("public").tag("public")
145                            Text("private").tag("private")
146                        }
147                        .pickerStyle(.segmented)
148                    }
149                } footer: {
150                    if !nameFixed {
151                        Text("Letters, digits and ._+- ; not starting with a dot.")
152                    }
153                }
154                Section {
155                    TextEditor(text: $content)
156                        .frame(minHeight: 200)
157                        .font(.gbMono(.caption))
158                        .autocorrectionDisabled()
159                        .textInputAutocapitalization(.never)
160                        .accessibilityIdentifier("snippet-content")
161                }
162                if let errorMessage {
163                    Section {
164                        GBNotice(errorMessage)
165                    }
166                }
167            }
168            .navigationTitle(heading)
169            .navigationBarTitleDisplayMode(.inline)
170            .toolbar {
171                ToolbarItem(placement: .cancellationAction) {
172                    Button("Cancel") { dismiss() }
173                        .disabled(working)
174                }
175                ToolbarItem(placement: .confirmationAction) {
176                    if working {
177                        ProgressView()
178                    } else {
179                        Button(submitLabel) {
180                            onSubmit(name, description, visibility, content)
181                        }
182                        .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || content.isEmpty)
183                        .accessibilityIdentifier("snippet-submit")
184                    }
185                }
186            }
187            .interactiveDismissDisabled(working)
188        }
189    }
190}
191
192nonisolated enum SnippetRoute: Hashable {
193    /// nil is the caller's own.
194    case list(owner: String?)
195    case snippet(id: String)
196}