gitbay/Views/Snippets/SnippetListView.swift
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}