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