a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Views/MRs/MRListView.swift

ui-smoke
gitbay-ios/gitbay/Views/MRs/MRListView.swift history · blame · raw

195 lines · 6868 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                        GBNotice(error)
 97                    }
 98                }
 99            }
100            .navigationTitle("New Merge Request")
101            .navigationBarTitleDisplayMode(.inline)
102            .toolbar {
103                ToolbarItem(placement: .cancellationAction) {
104                    Button("Cancel") { dismiss() }
105                }
106                ToolbarItem(placement: .confirmationAction) {
107                    if model.working {
108                        ProgressView()
109                    } else {
110                        Button("Create") {
111                            Task {
112                                if await model.create(
113                                    source: source, target: target,
114                                    title: title, body: bodyText
115                                ) != nil {
116                                    onCreated()
117                                }
118                            }
119                        }
120                        .disabled(
121                            source.trimmingCharacters(in: .whitespaces).isEmpty
122                                || target.trimmingCharacters(in: .whitespaces).isEmpty
123                                || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
124                        )
125                        .accessibilityIdentifier("mr-submit")
126                    }
127                }
128            }
129            .interactiveDismissDisabled(model.working)
130            .task {
131                await model.loadDefaultBranch()
132                if target.isEmpty, let branch = model.defaultBranch {
133                    target = branch
134                }
135            }
136        }
137    }
138}
139
140struct MRRow: View {
141    let mr: MergeRequest
142
143    var body: some View {
144        VStack(alignment: .leading, spacing: 4) {
145            HStack(alignment: .firstTextBaseline, spacing: 6) {
146                Text("!\(mr.number)")
147                    .font(.gbMono(.caption))
148                    .foregroundStyle(.secondary)
149                Text(mr.title)
150                    .font(.gbSans(.subheadline).weight(.medium))
151                    .lineLimit(2)
152            }
153            HStack(spacing: 6) {
154                MRStateBadge(state: mr.state)
155                Text(mr.source.isEmpty ? "(source gone)" : mr.source)
156                    .lineLimit(1)
157                Image(systemName: "arrow.right")
158                    .font(.gbSans(.caption2))
159                Text(mr.targetRef)
160                Spacer()
161                Text(mr.createdAt, format: .relative(presentation: .named))
162                    .foregroundStyle(.secondary)
163            }
164            .font(.gbSans(.caption))
165            .foregroundStyle(.secondary)
166        }
167        .padding(.vertical, 2)
168    }
169}
170
171struct MRStateBadge: View {
172    let state: String
173
174    var body: some View {
175        GBChip(state.replacingOccurrences(of: "_", with: " "), color)
176    }
177
178    /// The web's chip mapping: open=ok, merged=done, closed=bad,
179    /// source_gone=neutral.
180    private var color: Color {
181        switch state {
182        case "open": .gbOK
183        case "merged": .gbDone
184        case "closed": .gbBad
185        default: .secondary
186        }
187    }
188}
189
190/// MR navigation routes, separate from the repo stack's.
191nonisolated enum MRRoute: Hashable {
192    case list(repo: String)
193    case mr(repo: String, number: Int64)
194    case diff(repo: String, number: Int64)
195}