krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

main: 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}