gitbay/Views/Snippets/SnippetListView.swift

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

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