krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.0: DomainDig/WorkflowsView.swift · raw
1import SwiftUI
2
3private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
4 case existing
5 case new
6
7 var id: String { rawValue }
8}
9
10struct WorkflowsView: View {
11 @Environment(\.appDensity) private var appDensity
12 @Bindable var viewModel: DomainViewModel
13 @State private var purchaseService = PurchaseService.shared
14
15 @State private var showingCreateWorkflow = false
16
17 var body: some View {
18 let _ = purchaseService.currentTier
19
20 List {
21 if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) {
22 Section {
23 Text(limitMessage)
24 .font(appDensity.font(.caption))
25 .foregroundStyle(Color(.appTextSecondary))
26 }
27 .listRowBackground(Color(.appSurface))
28 }
29
30 workflowContent
31 }
32 .scrollContentBackground(.hidden)
33 .background(Color(.appBackground))
34 .refreshable {
35 await viewModel.refreshWorkflowList()
36 }
37 .navigationTitle("Workflows")
38 .toolbar {
39 ToolbarItemGroup(placement: .topBarTrailing) {
40 Button {
41 showingCreateWorkflow = true
42 } label: {
43 Image(systemName: "plus.circle")
44 }
45 .accessibilityLabel("Create workflow")
46
47 if !viewModel.workflows.isEmpty {
48 EditButton()
49 }
50 }
51 }
52 .sheet(isPresented: $showingCreateWorkflow) {
53 WorkflowComposerView(viewModel: viewModel)
54 }
55 .sheet(item: workflowSummaryBinding) { summary in
56 WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
57 }
58 }
59
60 private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
61 Binding(
62 get: { viewModel.latestWorkflowRunSummary },
63 set: { viewModel.latestWorkflowRunSummary = $0 }
64 )
65 }
66
67 @ViewBuilder
68 private var workflowContent: some View {
69 if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
70 Section("Workflow Run") {
71 VStack(alignment: .leading, spacing: 8) {
72 ProgressView(
73 value: Double(viewModel.batchCompletedCount),
74 total: Double(max(viewModel.batchTotalCount, 1))
75 )
76 .tint(Color(.statusInfo))
77
78 HStack {
79 Text(viewModel.batchProgressLabel)
80 .font(appDensity.font(.caption))
81 .foregroundStyle(Color(.appTextSecondary))
82 Spacer()
83 if viewModel.batchLookupRunning {
84 Button("Cancel") {
85 viewModel.cancelBatchLookup()
86 }
87 .buttonStyle(.bordered)
88 .font(appDensity.font(.caption2))
89 }
90 }
91 }
92 .padding(.vertical, 4)
93 }
94 .listRowBackground(Color(.appSurface))
95 }
96
97 if viewModel.workflows.isEmpty {
98 Section {
99 EmptyStateCardView(
100 title: "No Workflows Yet",
101 message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
102 suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
103 systemImage: "square.stack.3d.down.right",
104 showsCardBackground: false
105 )
106 }
107 .listRowBackground(Color(.appSurface))
108 } else {
109 ForEach(viewModel.workflows) { workflow in
110 NavigationLink {
111 WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
112 } label: {
113 WorkflowRowView(workflow: workflow)
114 }
115 .listRowBackground(Color(.appSurface))
116 }
117 .onDelete { offsets in
118 let workflows = offsets.map { viewModel.workflows[$0] }
119 workflows.forEach(viewModel.deleteWorkflow)
120 }
121 }
122 }
123}
124
125private struct WorkflowRowView: View {
126 @Environment(\.appDensity) private var appDensity
127
128 let workflow: DomainWorkflow
129
130 var body: some View {
131 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
132 HStack(alignment: .firstTextBaseline, spacing: 8) {
133 if workflow.collaboration?.isShared == true {
134 Image(systemName: "person.2.fill")
135 .font(.caption2)
136 .foregroundStyle(Color(.statusInfo))
137 .accessibilityLabel("Shared")
138 }
139 Text(workflow.name)
140 .font(appDensity.font(.callout, design: .default, weight: .semibold))
141 .foregroundStyle(.primary)
142 .lineLimit(2)
143 Spacer(minLength: 8)
144 Text("\(workflow.domains.count) domains")
145 .font(appDensity.font(.caption2))
146 .foregroundStyle(Color(.appTextSecondary))
147 }
148
149 Text("Updated \(workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))")
150 .font(appDensity.font(.caption2))
151 .foregroundStyle(Color(.appTextSecondary))
152
153 if let collaboration = workflow.collaboration, collaboration.isShared {
154 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
155 .font(appDensity.font(.caption2))
156 .foregroundStyle(Color(.appTextSecondary))
157 }
158
159 if let notes = workflow.notes, !notes.isEmpty {
160 Text(notes)
161 .font(appDensity.font(.caption))
162 .foregroundStyle(Color(.appTextSecondary))
163 .lineLimit(2)
164 }
165 }
166 .frame(maxWidth: .infinity, alignment: .leading)
167 .padding(.vertical, 4)
168 }
169}
170
171struct WorkflowDetailView: View {
172 @Environment(\.appDensity) private var appDensity
173 @Bindable var viewModel: DomainViewModel
174 let workflowID: UUID
175
176 @State private var showingEditor = false
177 @State private var draftDomain = ""
178 @State private var includeAllExports = false
179 @State private var shareEntity: ShareableEntity?
180
181 private var workflow: DomainWorkflow? {
182 viewModel.workflow(withID: workflowID)
183 }
184
185 private var latestSummary: WorkflowRunSummary? {
186 guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
187 return viewModel.latestWorkflowRunSummary
188 }
189
190 var body: some View {
191 Group {
192 if let workflow {
193 List {
194 Section("Overview") {
195 statRow(label: "Domains", value: "\(workflow.domains.count)")
196 statRow(label: "Created", value: workflow.createdAt.formatted(date: .abbreviated, time: .shortened))
197 statRow(label: "Updated", value: workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))
198 if let notes = workflow.notes, !notes.isEmpty {
199 Text(notes)
200 .font(appDensity.font(.caption))
201 .foregroundStyle(Color(.appTextSecondary))
202 }
203 if let collaboration = workflow.collaboration, collaboration.isShared {
204 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
205 .font(appDensity.font(.caption))
206 .foregroundStyle(Color(.appTextSecondary))
207 }
208 }
209
210 Section("Actions") {
211 Button {
212 viewModel.runWorkflow(workflow)
213 } label: {
214 Label("Run Workflow", systemImage: "play.fill")
215 }
216 .disabled(workflow.domains.isEmpty || viewModel.batchLookupRunning)
217
218 if viewModel.batchLookupRunning, viewModel.batchLookupSource == .workflow {
219 Button("Cancel Workflow Run", role: .destructive) {
220 viewModel.cancelBatchLookup()
221 }
222 }
223
224 Button {
225 showingEditor = true
226 } label: {
227 Label("Edit Workflow", systemImage: "pencil")
228 }
229 .disabled(!viewModel.canEdit(workflow))
230
231 Button {
232 shareEntity = .workflow(workflow.id)
233 } label: {
234 Label(workflow.collaboration?.isShared == true ? "Manage Share" : "Share Workflow", systemImage: "person.2")
235 }
236
237 if latestSummary != nil {
238 Toggle("Export all domains", isOn: $includeAllExports)
239 Menu {
240 Button("Export TXT") {
241 shareWorkflowResults(format: .text)
242 }
243 if FeatureAccessService.hasAccess(to: .advancedExports) {
244 Button("Export CSV") {
245 shareWorkflowResults(format: .csv)
246 }
247 Button("Export JSON") {
248 shareWorkflowResults(format: .json)
249 }
250 Button("Export Markdown") {
251 shareWorkflowResults(format: .markdown)
252 }
253 Button("Export PDF") {
254 shareWorkflowResults(format: .pdf)
255 }
256 } else {
257 Button("CSV Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
258 .disabled(true)
259 Button("JSON Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
260 .disabled(true)
261 Button("Markdown Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
262 .disabled(true)
263 Button("PDF Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
264 .disabled(true)
265 }
266 } label: {
267 Label("Export Results", systemImage: "square.and.arrow.up")
268 }
269 }
270 }
271
272 if let latestSummary {
273 Section("Latest Run") {
274 statRow(label: "Processed", value: "\(latestSummary.totalDomains)")
275 statRow(label: "Changed", value: "\(latestSummary.changedDomains)")
276 statRow(label: "Warnings", value: "\(latestSummary.warningDomains)")
277 statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)")
278 if !latestSummary.workflowInsights.isEmpty {
279 Text(latestSummary.workflowInsights[0].description)
280 .font(appDensity.font(.caption))
281 .foregroundStyle(Color(.appTextSecondary))
282 }
283
284 Button {
285 viewModel.latestWorkflowRunSummary = latestSummary
286 } label: {
287 Label("View Run Summary", systemImage: "list.bullet.rectangle")
288 }
289 }
290 }
291
292 Section("Add Domain") {
293 HStack {
294 TextField("example.com", text: $draftDomain)
295 .textInputAutocapitalization(.never)
296 .autocorrectionDisabled()
297 Button("Add") {
298 guard !draftDomain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
299 viewModel.addDomains([draftDomain], to: workflow)
300 draftDomain = ""
301 }
302 .disabled(!viewModel.canEdit(workflow))
303 }
304 }
305
306 Section("Domains") {
307 if workflow.domains.isEmpty {
308 Text("No domains in this workflow")
309 .font(appDensity.font(.caption))
310 .foregroundStyle(Color(.appTextSecondary))
311 } else {
312 ForEach(workflow.domains, id: \.self) { domain in
313 Button {
314 viewModel.openInspection(for: domain)
315 } label: {
316 HStack {
317 Text(domain)
318 .font(appDensity.font(.callout))
319 .foregroundStyle(.primary)
320 Spacer()
321 Image(systemName: "arrow.up.right.circle")
322 .foregroundStyle(Color(.appTextSecondary))
323 }
324 }
325 .buttonStyle(.plain)
326 }
327 .onDelete { offsets in
328 guard viewModel.canEdit(workflow) else { return }
329 viewModel.removeWorkflowDomains(at: offsets, from: workflow)
330 }
331 .onMove { offsets, destination in
332 guard viewModel.canEdit(workflow) else { return }
333 viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
334 }
335 }
336 }
337 }
338 .scrollContentBackground(.hidden)
339 .background(Color(.appBackground))
340 .navigationTitle(workflow.name)
341 .toolbar {
342 if !workflow.domains.isEmpty {
343 EditButton()
344 .disabled(!viewModel.canEdit(workflow))
345 }
346 }
347 .sheet(isPresented: $showingEditor) {
348 WorkflowComposerView(viewModel: viewModel, workflow: workflow)
349 }
350 .sheet(item: workflowSummaryBinding) { summary in
351 WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
352 }
353 .sheet(item: $shareEntity) { entity in
354 CloudSharingSheet(entity: entity, title: workflow.name)
355 }
356 } else {
357 Text("Workflow not found")
358 .font(appDensity.font(.callout))
359 .foregroundStyle(Color(.appTextSecondary))
360 }
361 }
362 }
363
364 private func statRow(label: String, value: String) -> some View {
365 HStack {
366 Text(label)
367 .font(appDensity.font(.caption))
368 .foregroundStyle(Color(.appTextSecondary))
369 Spacer()
370 Text(value)
371 .font(appDensity.font(.callout))
372 .foregroundStyle(.primary)
373 }
374 }
375
376 private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
377 Binding(
378 get: {
379 guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
380 return viewModel.latestWorkflowRunSummary
381 },
382 set: { _ in
383 viewModel.latestWorkflowRunSummary = nil
384 }
385 )
386 }
387
388 private func shareWorkflowResults(format: DomainExportFormat) {
389 guard let latestSummary else { return }
390
391 let changedOnly = !includeAllExports
392 let formatter = DateFormatter()
393 formatter.dateFormat = "yyyyMMdd_HHmmss"
394 let timestamp = formatter.string(from: Date())
395 let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)"
396 guard let data = viewModel.exportWorkflowData(summary: latestSummary, changedOnly: changedOnly, format: format) else {
397 return
398 }
399
400 ExportPresenter.share(filename: filename, data: data)
401 }
402}
403
404struct WorkflowComposerView: View {
405 @Environment(\.dismiss) private var dismiss
406 @Bindable var viewModel: DomainViewModel
407 let workflow: DomainWorkflow?
408
409 @State private var name = ""
410 @State private var domainsText = ""
411 @State private var notes = ""
412
413 init(viewModel: DomainViewModel, workflow: DomainWorkflow? = nil) {
414 self.viewModel = viewModel
415 self.workflow = workflow
416 _name = State(initialValue: workflow?.name ?? "")
417 _domainsText = State(initialValue: workflow?.domains.joined(separator: "\n") ?? "")
418 _notes = State(initialValue: workflow?.notes ?? "")
419 }
420
421 var body: some View {
422 NavigationStack {
423 Form {
424 Section("Workflow") {
425 TextField("Name", text: $name)
426 TextField("Notes", text: $notes, axis: .vertical)
427 .lineLimit(3...6)
428 }
429
430 Section("Domains") {
431 TextField("example.com\napple.com", text: $domainsText, axis: .vertical)
432 .textInputAutocapitalization(.never)
433 .autocorrectionDisabled()
434 .lineLimit(6...14)
435 }
436 }
437 .navigationTitle(workflow == nil ? "New Workflow" : "Edit Workflow")
438 .toolbar {
439 ToolbarItem(placement: .cancellationAction) {
440 Button("Cancel") {
441 dismiss()
442 }
443 }
444 ToolbarItem(placement: .confirmationAction) {
445 Button("Save") {
446 saveWorkflow()
447 }
448 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || parsedDomains.isEmpty)
449 }
450 }
451 }
452 }
453
454 private var parsedDomains: [String] {
455 domainsText
456 .components(separatedBy: CharacterSet(charactersIn: ",\n"))
457 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
458 .filter { !$0.isEmpty }
459 }
460
461 private func saveWorkflow() {
462 if let workflow {
463 viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
464 dismiss()
465 return
466 }
467
468 if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil {
469 dismiss()
470 }
471 }
472}
473
474struct WorkflowRunSummaryView: View {
475 @Bindable var viewModel: DomainViewModel
476 let summary: WorkflowRunSummary
477
478 @State private var showAllResults = false
479
480 private var visibleResults: [BatchLookupResult] {
481 showAllResults ? summary.results : summary.results.filter(\.hasMeaningfulChange)
482 }
483
484 var body: some View {
485 NavigationStack {
486 List {
487 Section("Overview") {
488 statRow(label: "Workflow", value: summary.workflowName)
489 statRow(label: "Processed", value: "\(summary.totalDomains)")
490 statRow(label: "Changed", value: "\(summary.changedDomains)")
491 statRow(label: "Warnings", value: "\(summary.warningDomains)")
492 statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
493 statRow(label: "Finished", value: summary.generatedAt.formatted(date: .abbreviated, time: .shortened))
494 }
495
496 Section {
497 Toggle("Show unchanged domains", isOn: $showAllResults)
498 }
499
500 if !summary.workflowInsights.isEmpty {
501 Section("Workflow Insights") {
502 ForEach(summary.workflowInsights) { insight in
503 VStack(alignment: .leading, spacing: 4) {
504 Text(insight.description)
505 .font(.system(.callout, design: .monospaced))
506 .foregroundStyle(.primary)
507 Text(insight.domainsInvolved.joined(separator: ", "))
508 .font(.system(.caption, design: .monospaced))
509 .foregroundStyle(Color(.appTextSecondary))
510 }
511 }
512 }
513 }
514
515 Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") {
516 if visibleResults.isEmpty {
517 Text("No domains with meaningful changes or warnings")
518 .font(.system(.caption, design: .monospaced))
519 .foregroundStyle(Color(.appTextSecondary))
520 } else {
521 ForEach(visibleResults) { result in
522 if let entry = viewModel.historyEntry(for: result) {
523 NavigationLink {
524 HistoryDetailView(viewModel: viewModel, entry: entry)
525 } label: {
526 BatchResultRowView(result: result)
527 }
528 } else {
529 BatchResultRowView(result: result)
530 }
531 }
532 }
533 }
534 }
535 .navigationTitle("Workflow Summary")
536 .toolbar {
537 ToolbarItemGroup(placement: .topBarTrailing) {
538 Menu {
539 Button("Export TXT") {
540 share(format: .text)
541 }
542 if FeatureAccessService.hasAccess(to: .advancedExports) {
543 Button("Export CSV") {
544 share(format: .csv)
545 }
546 Button("Export JSON") {
547 share(format: .json)
548 }
549 Button("Export Markdown") {
550 share(format: .markdown)
551 }
552 Button("Export PDF") {
553 share(format: .pdf)
554 }
555 } else {
556 Button("CSV Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
557 .disabled(true)
558 Button("JSON Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
559 .disabled(true)
560 Button("Markdown Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
561 .disabled(true)
562 Button("PDF Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
563 .disabled(true)
564 }
565 } label: {
566 Image(systemName: "square.and.arrow.up")
567 }
568 .accessibilityLabel("Export summary")
569
570 if let workflow = viewModel.workflow(withID: summary.workflowID) {
571 Button {
572 viewModel.runWorkflow(workflow)
573 } label: {
574 Image(systemName: "arrow.clockwise")
575 }
576 .accessibilityLabel("Re-run workflow")
577 .disabled(viewModel.batchLookupRunning)
578 }
579 }
580 }
581 }
582 }
583
584 private func statRow(label: String, value: String) -> some View {
585 HStack {
586 Text(label)
587 .font(.system(.caption, design: .monospaced))
588 .foregroundStyle(Color(.appTextSecondary))
589 Spacer()
590 Text(value)
591 .font(.system(.callout, design: .monospaced))
592 .foregroundStyle(.primary)
593 }
594 }
595
596 private func share(format: DomainExportFormat) {
597 let changedOnly = !showAllResults
598 let formatter = DateFormatter()
599 formatter.dateFormat = "yyyyMMdd_HHmmss"
600 let timestamp = formatter.string(from: Date())
601 let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)"
602 guard let data = viewModel.exportWorkflowData(summary: summary, changedOnly: changedOnly, format: format) else {
603 return
604 }
605
606 ExportPresenter.share(filename: filename, data: data)
607 }
608}
609
610struct WorkflowBulkAddSheet: View {
611 @Environment(\.dismiss) private var dismiss
612 @Bindable var viewModel: DomainViewModel
613 let title: String
614 let availableDomains: [String]
615
616 @State private var mode: WorkflowDestinationMode = .existing
617 @State private var selectedDomains: Set<String>
618 @State private var selectedWorkflowID: UUID?
619 @State private var newWorkflowName = ""
620 @State private var newWorkflowNotes = ""
621
622 init(viewModel: DomainViewModel, title: String, availableDomains: [String]) {
623 self.viewModel = viewModel
624 self.title = title
625 self.availableDomains = Array(Set(availableDomains)).sorted()
626 _selectedDomains = State(initialValue: Set(availableDomains))
627 _selectedWorkflowID = State(initialValue: viewModel.workflows.first?.id)
628 }
629
630 var body: some View {
631 NavigationStack {
632 List {
633 Section("Selection") {
634 ForEach(availableDomains, id: \.self) { domain in
635 Button {
636 toggle(domain)
637 } label: {
638 HStack {
639 Text(domain)
640 .foregroundStyle(.primary)
641 Spacer()
642 Image(systemName: selectedDomains.contains(domain) ? "checkmark.circle.fill" : "circle")
643 .foregroundStyle(selectedDomains.contains(domain) ? Color(.statusInfo) : .secondary)
644 }
645 }
646 .buttonStyle(.plain)
647 .accessibilityAddTraits(selectedDomains.contains(domain) ? .isSelected : [])
648 }
649 }
650
651 Section("Destination") {
652 Picker("Destination", selection: $mode) {
653 Text("Existing").tag(WorkflowDestinationMode.existing)
654 Text("New").tag(WorkflowDestinationMode.new)
655 }
656 .pickerStyle(.segmented)
657
658 if mode == .existing, !viewModel.workflows.isEmpty {
659 Picker("Workflow", selection: $selectedWorkflowID) {
660 ForEach(viewModel.workflows) { workflow in
661 Text(workflow.name).tag(Optional(workflow.id))
662 }
663 }
664 } else {
665 TextField("Workflow name", text: $newWorkflowName)
666 TextField("Notes", text: $newWorkflowNotes, axis: .vertical)
667 .lineLimit(2...5)
668 }
669 }
670 }
671 .navigationTitle(title)
672 .toolbar {
673 ToolbarItem(placement: .cancellationAction) {
674 Button("Cancel") {
675 dismiss()
676 }
677 }
678 ToolbarItem(placement: .confirmationAction) {
679 Button("Save") {
680 save()
681 }
682 .disabled(!canSave)
683 }
684 }
685 .onAppear {
686 if viewModel.workflows.isEmpty {
687 mode = .new
688 }
689 }
690 }
691 }
692
693 private var selectedDomainList: [String] {
694 availableDomains.filter { selectedDomains.contains($0) }
695 }
696
697 private var canSave: Bool {
698 guard !selectedDomainList.isEmpty else { return false }
699 switch mode {
700 case .existing:
701 return selectedWorkflowID != nil
702 case .new:
703 return !newWorkflowName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
704 }
705 }
706
707 private func toggle(_ domain: String) {
708 if selectedDomains.contains(domain) {
709 selectedDomains.remove(domain)
710 } else {
711 selectedDomains.insert(domain)
712 }
713 }
714
715 private func save() {
716 switch mode {
717 case .existing:
718 guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
719 viewModel.addDomains(selectedDomainList, to: workflow)
720 dismiss()
721 case .new:
722 guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else {
723 return
724 }
725 dismiss()
726 }
727 }
728}