gitbay/Views/MRs/MRListView.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbay/Views/MRs/MRListView.swift history · blame · raw

200 lines · 7089 bytes

  1import SwiftUI
  2
  3struct MRListView: View {
  4
  5    @State private var model: MRListViewModel
  6    @State private var createModel: MRCreateViewModel
  7    @State private var composing = false
  8
  9    init(client: GitbayClient, repo: String) {
 10        _model = State(initialValue: MRListViewModel(client: client, repoPath: repo))
 11        _createModel = State(initialValue: MRCreateViewModel(client: client, repoPath: repo))
 12    }
 13
 14    var body: some View {
 15        List {
 16            Picker("State", selection: Bindable(model).filter) {
 17                ForEach(MRListViewModel.StateFilter.allCases) { filter in
 18                    Text(filter.rawValue.capitalized).tag(filter)
 19                }
 20            }
 21            .pickerStyle(.segmented)
 22            .listRowBackground(Color.clear)
 23            .listRowInsets(EdgeInsets())
 24
 25            ForEach(model.state.value ?? []) { mr in
 26                NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: mr.number)) {
 27                    MRRow(mr: mr)
 28                }
 29            }
 30            PageFooter(list: model.list)
 31        }
 32        .overlay { LoadStateOverlay(state: model.state) }
 33        .navigationTitle("Merge Requests")
 34        .navigationBarTitleDisplayMode(.inline)
 35        .toolbar {
 36            ToolbarItem(placement: .topBarTrailing) {
 37                Button {
 38                    composing = true
 39                } label: {
 40                    Image(systemName: "plus")
 41                }
 42                .accessibilityIdentifier("mr-create-button")
 43            }
 44        }
 45        .sheet(isPresented: $composing) {
 46            MRCreateSheet(model: createModel) {
 47                composing = false
 48                Task { await model.load() }
 49            }
 50        }
 51        .task { await model.load() }
 52        .refreshable { await model.load() }
 53    }
 54}
 55
 56/// Source and target are typed, not picked: no command lists branches
 57/// yet (krz/gitbay#45). Target prefills with the default branch.
 58private struct MRCreateSheet: View {
 59
 60    let model: MRCreateViewModel
 61    let onCreated: () -> Void
 62
 63    @Environment(\.dismiss) private var dismiss
 64    @State private var source = ""
 65    @State private var target = ""
 66    @State private var title = ""
 67    @State private var bodyText = ""
 68
 69    var body: some View {
 70        NavigationStack {
 71            Form {
 72                Section("Branches") {
 73                    TextField("Source branch", text: $source)
 74                        .autocorrectionDisabled()
 75                        .textInputAutocapitalization(.never)
 76                        .accessibilityIdentifier("mr-source")
 77                    TextField("Target branch", text: $target)
 78                        .autocorrectionDisabled()
 79                        .textInputAutocapitalization(.never)
 80                        .accessibilityIdentifier("mr-target")
 81                }
 82                Section("Title") {
 83                    TextField("Title", text: $title, axis: .vertical)
 84                        .lineLimit(1...3)
 85                        .autocorrectionDisabled()
 86                        .accessibilityIdentifier("mr-title")
 87                }
 88                Section("Body") {
 89                    TextEditor(text: $bodyText)
 90                        .frame(minHeight: 120)
 91                        .autocorrectionDisabled()
 92                        .accessibilityIdentifier("mr-body")
 93                }
 94                if let error = model.errorMessage {
 95                    Section {
 96                        Label(error, systemImage: "exclamationmark.triangle")
 97                            .foregroundStyle(.red)
 98                            .font(.subheadline)
 99                    }
100                }
101            }
102            .navigationTitle("New Merge Request")
103            .navigationBarTitleDisplayMode(.inline)
104            .toolbar {
105                ToolbarItem(placement: .cancellationAction) {
106                    Button("Cancel") { dismiss() }
107                }
108                ToolbarItem(placement: .confirmationAction) {
109                    if model.working {
110                        ProgressView()
111                    } else {
112                        Button("Create") {
113                            Task {
114                                if await model.create(
115                                    source: source, target: target,
116                                    title: title, body: bodyText
117                                ) != nil {
118                                    onCreated()
119                                }
120                            }
121                        }
122                        .disabled(
123                            source.trimmingCharacters(in: .whitespaces).isEmpty
124                                || target.trimmingCharacters(in: .whitespaces).isEmpty
125                                || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
126                        )
127                        .accessibilityIdentifier("mr-submit")
128                    }
129                }
130            }
131            .interactiveDismissDisabled(model.working)
132            .task {
133                await model.loadDefaultBranch()
134                if target.isEmpty, let branch = model.defaultBranch {
135                    target = branch
136                }
137            }
138        }
139    }
140}
141
142struct MRRow: View {
143    let mr: MergeRequest
144
145    var body: some View {
146        VStack(alignment: .leading, spacing: 4) {
147            HStack(alignment: .firstTextBaseline, spacing: 6) {
148                Text("!\(mr.number)")
149                    .font(.caption.monospaced())
150                    .foregroundStyle(.secondary)
151                Text(mr.title)
152                    .font(.subheadline.weight(.medium))
153                    .lineLimit(2)
154            }
155            HStack(spacing: 6) {
156                MRStateBadge(state: mr.state)
157                Text(mr.source.isEmpty ? "(source gone)" : mr.source)
158                    .lineLimit(1)
159                Image(systemName: "arrow.right")
160                    .font(.caption2)
161                Text(mr.targetRef)
162                Spacer()
163                Text(mr.createdAt, format: .relative(presentation: .named))
164                    .foregroundStyle(.tertiary)
165            }
166            .font(.caption)
167            .foregroundStyle(.secondary)
168        }
169        .padding(.vertical, 2)
170    }
171}
172
173struct MRStateBadge: View {
174    let state: String
175
176    var body: some View {
177        Text(state.replacingOccurrences(of: "_", with: " "))
178            .font(.caption2.weight(.medium))
179            .padding(.horizontal, 6)
180            .padding(.vertical, 1)
181            .background(color.opacity(0.15), in: Capsule())
182            .foregroundStyle(color)
183    }
184
185    private var color: Color {
186        switch state {
187        case "open": .green
188        case "merged": .purple
189        case "closed": .red
190        default: .secondary
191        }
192    }
193}
194
195/// MR navigation routes, separate from the repo stack's.
196nonisolated enum MRRoute: Hashable {
197    case list(repo: String)
198    case mr(repo: String, number: Int64)
199    case diff(repo: String, number: Int64)
200}