| @@ -18,14 +18,17 @@ struct MirrorsView: View { |
| 18 | if let error = model.actionError, !adding { |
18 | if let error = model.actionError, !adding { |
| 19 | Section { GBNotice(error, .gbWarn) } |
19 | Section { GBNotice(error, .gbWarn) } |
| 20 | } else if scheduled { |
20 | } else if scheduled { |
| 21 | Section { GBNotice("Sync scheduled. The list updates as each mirror finishes.", .gbOK) } |
21 | Section { GBNotice("Sync scheduled. Pull to refresh for each mirror's result.", .gbOK) } |
| 22 | } |
22 | } |
| 23 | Section { |
23 | Section { |
| 24 | ForEach(model.state.value ?? []) { mirror in |
24 | ForEach(model.state.value ?? []) { mirror in |
| 25 | row(mirror) |
25 | row(mirror) |
| 26 | .swipeActions { |
26 | .swipeActions { |
| 27 | Button("Remove", role: .destructive) { removing = mirror } |
27 | Button("Remove", role: .destructive) { |
| 28 | .disabled(model.working) |
28 | scheduled = false |
| |
29 | removing = mirror |
| |
30 | } |
| |
31 | .disabled(model.working) |
| 29 | } |
32 | } |
| 30 | } |
33 | } |
| 31 | } footer: { |
34 | } footer: { |
| @@ -51,7 +54,10 @@ struct MirrorsView: View { |
| 51 | .navigationBarTitleDisplayMode(.inline) |
54 | .navigationBarTitleDisplayMode(.inline) |
| 52 | .toolbar { |
55 | .toolbar { |
| 53 | ToolbarItem(placement: .topBarTrailing) { |
56 | ToolbarItem(placement: .topBarTrailing) { |
| 54 | Button { adding = true } label: { Image(systemName: "plus") } |
57 | Button { |
| |
58 | scheduled = false |
| |
59 | adding = true |
| |
60 | } label: { Image(systemName: "plus") } |
| 55 | .accessibilityLabel("Add Mirror") |
61 | .accessibilityLabel("Add Mirror") |
| 56 | .disabled(model.working) |
62 | .disabled(model.working) |
| 57 | .accessibilityIdentifier("mirror-add-button") |
63 | .accessibilityIdentifier("mirror-add-button") |
| @@ -115,6 +121,7 @@ private struct MirrorAddSheet: View { |
| 115 | @State private var pull = false |
121 | @State private var pull = false |
| 116 | @State private var username = "" |
122 | @State private var username = "" |
| 117 | @State private var token = "" |
123 | @State private var token = "" |
| |
124 | @State private var attempted = false |
| 118 | |
125 | |
| 119 | var body: some View { |
126 | var body: some View { |
| 120 | NavigationStack { |
127 | NavigationStack { |
| @@ -146,7 +153,7 @@ private struct MirrorAddSheet: View { |
| 146 | } footer: { |
153 | } footer: { |
| 147 | Text("For a remote that needs credentials. The token is stored for syncs and not shown again.") |
154 | Text("For a remote that needs credentials. The token is stored for syncs and not shown again.") |
| 148 | } |
155 | } |
| 149 | if let error = model.actionError { |
156 | if attempted, let error = model.actionError { |
| 150 | Section { GBNotice(error, .gbWarn) } |
157 | Section { GBNotice(error, .gbWarn) } |
| 151 | } |
158 | } |
| 152 | } |
159 | } |
| @@ -162,6 +169,7 @@ private struct MirrorAddSheet: View { |
| 162 | } else { |
169 | } else { |
| 163 | Button("Add") { |
170 | Button("Add") { |
| 164 | Task { |
171 | Task { |
| |
172 | attempted = true |
| 165 | guard await model.add(url: url, pull: pull, username: username, token: token) else { return } |
173 | guard await model.add(url: url, pull: pull, username: username, token: token) else { return } |
| 166 | // A secure field that still holds text as the sheet |
174 | // A secure field that still holds text as the sheet |
| 167 | // goes makes iOS offer to save it as a password. |
175 | // goes makes iOS offer to save it as a password. |
| @@ -207,7 +215,7 @@ struct PageDomainsView: View { |
| 207 | VStack(alignment: .leading, spacing: 3) { |
215 | VStack(alignment: .leading, spacing: 3) { |
| 208 | Text(claim.domain).font(.gbMono(.subheadline)) |
216 | Text(claim.domain).font(.gbMono(.subheadline)) |
| 209 | HStack(spacing: 6) { |
217 | HStack(spacing: 6) { |
| 210 | GBChip(claim.isVerified ? "verified" : "pending", claim.isVerified ? Color.gbOK : Color.gbWarn) |
218 | GBChip(claim.state, claim.isVerified ? Color.gbOK : claim.isPending ? Color.gbWarn : Color.secondary) |
| 211 | if let at = claim.verifiedAt { |
219 | if let at = claim.verifiedAt { |
| 212 | Text("since \(at.formatted(date: .abbreviated, time: .omitted))") |
220 | Text("since \(at.formatted(date: .abbreviated, time: .omitted))") |
| 213 | .font(.gbSans(.caption2)) |
221 | .font(.gbSans(.caption2)) |
| @@ -218,9 +226,12 @@ struct PageDomainsView: View { |
| 218 | .padding(.vertical, 2) |
226 | .padding(.vertical, 2) |
| 219 | .accessibilityElement(children: .combine) |
227 | .accessibilityElement(children: .combine) |
| 220 | .swipeActions { |
228 | .swipeActions { |
| 221 | Button("Remove", role: .destructive) { removing = claim } |
229 | Button("Remove", role: .destructive) { |
| 222 | .disabled(model.working) |
230 | verified = nil |
| 223 | if !claim.isVerified { |
231 | removing = claim |
| |
232 | } |
| |
233 | .disabled(model.working) |
| |
234 | if claim.isPending { |
| 224 | Button("Verify") { verify(claim.domain) } |
235 | Button("Verify") { verify(claim.domain) } |
| 225 | .tint(.gbAccent) |
236 | .tint(.gbAccent) |
| 226 | .disabled(model.working) |
237 | .disabled(model.working) |
| @@ -229,7 +240,7 @@ struct PageDomainsView: View { |
| 229 | } |
240 | } |
| 230 | } footer: { |
241 | } footer: { |
| 231 | if model.state.value != nil { |
242 | if model.state.value != nil { |
| 232 | Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it.") |
243 | Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it; an expired one has to be claimed again.") |
| 233 | } |
244 | } |
| 234 | } |
245 | } |
| 235 | } |
246 | } |
| @@ -238,7 +249,10 @@ struct PageDomainsView: View { |
| 238 | .navigationBarTitleDisplayMode(.inline) |
249 | .navigationBarTitleDisplayMode(.inline) |
| 239 | .toolbar { |
250 | .toolbar { |
| 240 | ToolbarItem(placement: .topBarTrailing) { |
251 | ToolbarItem(placement: .topBarTrailing) { |
| 241 | Button { adding = true } label: { Image(systemName: "plus") } |
252 | Button { |
| |
253 | verified = nil |
| |
254 | adding = true |
| |
255 | } label: { Image(systemName: "plus") } |
| 242 | .accessibilityLabel("Claim Domain") |
256 | .accessibilityLabel("Claim Domain") |
| 243 | .disabled(model.working) |
257 | .disabled(model.working) |
| 244 | .accessibilityIdentifier("domain-add-button") |
258 | .accessibilityIdentifier("domain-add-button") |
| @@ -283,23 +297,20 @@ private struct DomainClaimSheet: View { |
| 283 | |
297 | |
| 284 | @State private var domain = "" |
298 | @State private var domain = "" |
| 285 | @State private var challenge: DomainChallenge? |
299 | @State private var challenge: DomainChallenge? |
| |
300 | @State private var attempted = false |
| 286 | |
301 | |
| 287 | var body: some View { |
302 | var body: some View { |
| 288 | NavigationStack { |
303 | NavigationStack { |
| 289 | Form { |
304 | Form { |
| 290 | if let challenge { |
305 | if let challenge { |
| 291 | Section { |
306 | Section { |
| 292 | LabeledContent("Name") { |
307 | record("Name", challenge.name, id: "domain-copy-name") |
| 293 | Text(challenge.name).font(.gbMono(.caption)).textSelection(.enabled) |
| |
| 294 | } |
| |
| 295 | LabeledContent("Type", value: "TXT") |
308 | LabeledContent("Type", value: "TXT") |
| 296 | LabeledContent("Value") { |
309 | record("Value", challenge.value, id: "domain-copy-value") |
| 297 | Text(challenge.value).font(.gbMono(.caption)).textSelection(.enabled) |
| |
| 298 | } |
| |
| 299 | } header: { |
310 | } header: { |
| 300 | Text("Create this DNS record") |
311 | Text("Create this DNS record") |
| 301 | } footer: { |
312 | } footer: { |
| 302 | Text("Then verify. An unverified claim expires after a few days; it can also be verified later from the list.") |
313 | Text("This record is shown only now. Create it, then verify, here or later from the list. An unverified claim expires after a few days.") |
| 303 | } |
314 | } |
| 304 | } else { |
315 | } else { |
| 305 | Section { |
316 | Section { |
| @@ -312,7 +323,7 @@ private struct DomainClaimSheet: View { |
| 312 | Text("A domain you control. Claiming it gives you a DNS record that proves it.") |
323 | Text("A domain you control. Claiming it gives you a DNS record that proves it.") |
| 313 | } |
324 | } |
| 314 | } |
325 | } |
| 315 | if let error = model.actionError { |
326 | if attempted, let error = model.actionError { |
| 316 | Section { GBNotice(error, .gbWarn) } |
327 | Section { GBNotice(error, .gbWarn) } |
| 317 | } |
328 | } |
| 318 | } |
329 | } |
| @@ -329,19 +340,44 @@ private struct DomainClaimSheet: View { |
| 329 | ProgressView() |
340 | ProgressView() |
| 330 | } else if let challenge { |
341 | } else if let challenge { |
| 331 | Button("Verify") { |
342 | Button("Verify") { |
| 332 | Task { if await model.verify(challenge.domain) { onDone(challenge.domain) } } |
343 | Task { |
| |
344 | attempted = true |
| |
345 | if await model.verify(challenge.domain) { onDone(challenge.domain) } |
| |
346 | } |
| 333 | } |
347 | } |
| 334 | .accessibilityIdentifier("domain-verify") |
348 | .accessibilityIdentifier("domain-verify") |
| 335 | } else { |
349 | } else { |
| 336 | Button("Claim") { |
350 | Button("Claim") { |
| 337 | Task { challenge = await model.add(domain) } |
351 | Task { |
| |
352 | attempted = true |
| |
353 | challenge = await model.add(domain) |
| |
354 | } |
| 338 | } |
355 | } |
| 339 | .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty) |
356 | .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty) |
| 340 | .accessibilityIdentifier("domain-submit") |
357 | .accessibilityIdentifier("domain-submit") |
| 341 | } |
358 | } |
| 342 | } |
359 | } |
| 343 | } |
360 | } |
| 344 | .interactiveDismissDisabled(model.working) |
361 | // The record cannot be fetched again, so it closes only by Done. |
| |
362 | .interactiveDismissDisabled(model.working || challenge != nil) |
| |
363 | } |
| |
364 | } |
| |
365 | |
| |
366 | private func record(_ label: String, _ text: String, id: String) -> some View { |
| |
367 | HStack(alignment: .firstTextBaseline) { |
| |
368 | VStack(alignment: .leading, spacing: 2) { |
| |
369 | Text(label).font(.gbSans(.caption)).foregroundStyle(.secondary) |
| |
370 | Text(text).font(.gbMono(.caption)).textSelection(.enabled) |
| |
371 | } |
| |
372 | Spacer() |
| |
373 | Button { |
| |
374 | UIPasteboard.general.string = text |
| |
375 | } label: { |
| |
376 | Image(systemName: "doc.on.doc") |
| |
377 | } |
| |
378 | .buttonStyle(.borderless) |
| |
379 | .accessibilityLabel("Copy \(label)") |
| |
380 | .accessibilityIdentifier(id) |
| 345 | } |
381 | } |
| 346 | } |
382 | } |
| 347 | } |
383 | } |