// // AdminBrokenBuildsView.swift // FTStore Admin // // Builds people say are broken, and what an operator decides about them. // Mirrors the Broken builds tab of /admin/reviews. // // The second half of a broken report. Reporting one from FTStore writes a // request and nothing more: nobody else sees it, and the store says nothing. // This is where somebody looks and answers, and the answer is what gets // published. **The gap is the point** — a flag anyone could raise from the app // is a way for anyone to put a warning on somebody else's build, and a warning // is the one thing on a listing that stops people installing it. // // Confirming names a version, and that is what turns one stored decision into // two published states: the version confirmed reads as broken, anything newer // reads as *may be* broken, because nobody has looked at it. Clearing takes // both off at once. // // Not the same as flagging a build corrupted in the Library. That says the file // is bad and the download route refuses it; this says the build installs and // runs and the tweak inside it does not work, which is something only somebody // who ran it can know. It is a warning rather than a wall, and the install // button stays where it is. // import SwiftUI struct AdminBrokenBuildsView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _rows = AdminLoader() @State private var _search = "" @State private var _busy = false @State private var _banner: String? /// Which listing's form is open. One at a time, because confirming is a /// decision about one build and a screen of open forms invites the wrong one /// being filled in. @State private var _editing: String? @State private var _version = "" @State private var _note = "" @State private var _clearing: AdminBrokenBuild? private var _all: [AdminBrokenBuild] { let rows = _rows.value?.data ?? [] let query = _search.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return rows } return rows.filter { $0.matches(query) } } var body: some View { List { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } if let stats = _rows.value?.stats { Section { LazyVGrid( columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10 ) { AdminKPITile( title: "Waiting on you", value: AdminFormat.count(stats.pending), detail: "reported, not answered", icon: "exclamationmark.triangle", tint: stats.pending > 0 ? .orange : AdminTheme.brand ) AdminKPITile( title: "Flagged", value: AdminFormat.count(stats.flagged), detail: "warning in the store", icon: "exclamationmark.octagon", tint: stats.flagged > 0 ? .red : AdminTheme.brand ) } .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) .listRowBackground(Color.clear) } } if _rows.isLoading && !_rows.hasLoaded { AdminLoadingRow() } else if let error = _rows.errorMessage, !_rows.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else if _all.isEmpty { AdminEmptyRow( icon: "checkmark.shield", title: "Nothing reported broken", detail: "A thumbs down in FTStore can ask for a build to be checked. Those land here." ) } else { ForEach(_all) { row in Section { _row(row) if _editing == row.appKey { _form(row) } } footer: { if _editing == row.appKey { Text("Everyone sees this version marked broken, and every version after it marked as possibly broken until you clear it. The build still installs: this is a warning, not a block.") } } } } } .navigationTitle("Broken builds") .navigationBarTitleDisplayMode(.inline) .searchable(text: $_search, prompt: Text("Search builds")) .refreshable { await _reload() } .task { await _reload() } .alert( "Say this build works again?", isPresented: Binding( get: { _clearing != nil }, set: { if !$0 { _clearing = nil } } ), presenting: _clearing ) { row in Button("It works again", role: .destructive) { _clear(row) } Button("Cancel", role: .cancel) {} } message: { _ in Text("The warning comes off, and so does the one on every version after it. The reports stay where they are, on the reviews people wrote.") } } // MARK: Rows @ViewBuilder private func _row(_ row: AdminBrokenBuild) -> some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Image(systemName: row.isFlagged ? "exclamationmark.octagon.fill" : "exclamationmark.triangle.fill") .font(.caption) .foregroundStyle(row.isFlagged ? .red : .orange) Text(row.appName) .font(.subheadline.weight(.medium)) .lineLimit(1) } // The key rather than the bundle id: two tweaks of one app share one, // and this says which listing carries the warning. Text(verbatim: row.appKey) .font(.caption2.monospaced()) .foregroundStyle(.tertiary) .lineLimit(1) .truncationMode(.middle) Text(verbatim: _reportLine(row)) .font(.caption) .foregroundStyle(.secondary) if let flag = row.flag { Text(verbatim: .localized( "Version %@ reads as broken, anything newer as possibly broken.", flag.version )) .font(.caption) .foregroundStyle(.red) if let note = flag.note, !note.isEmpty { Text(verbatim: note) .font(.caption2) .foregroundStyle(.secondary) } } HStack(spacing: 8) { Button { if _editing == row.appKey { _editing = nil } else { _editing = row.appKey _version = row.flag?.version ?? row.reportedVersion ?? "" _note = row.flag?.note ?? "" } } label: { Label( row.isFlagged ? "Change the flag" : "Confirm broken", systemImage: "exclamationmark.octagon" ) } .buttonStyle(.bordered) .controlSize(.small) .disabled(_busy) if row.isFlagged { Button { _clearing = row } label: { Label("It works again", systemImage: "checkmark") } .buttonStyle(.bordered) .controlSize(.small) .disabled(_busy) } } .font(.caption) } .padding(.vertical, 2) } private func _reportLine(_ row: AdminBrokenBuild) -> String { var parts: [String] = [] if row.reports > 0 { parts.append(row.reports == 1 ? String.localized("1 asked for a check") : .localized("%lld asked for a check", row.reports)) } else { parts.append(.localized("Nobody is asking any more")) } if let at = row.lastReportAt { parts.append(AdminFormat.relative(at)) } return parts.joined(separator: " · ") } @ViewBuilder private func _form(_ row: AdminBrokenBuild) -> some View { TextField("Broken version", text: $_version) TextField("What's wrong", text: $_note) Button { _confirm(row) } label: { HStack { Label("Publish the warning", systemImage: "megaphone") if _busy { Spacer() ProgressView() } } } .disabled(_busy || _version.trimmingCharacters(in: .whitespaces).isEmpty) Button("Cancel", role: .cancel) { _editing = nil } .disabled(_busy) } // MARK: Actions private func _reload() async { await _rows.load { try await session.get("/api/reviews/broken", as: AdminBrokenBuildsResponse.self) } } private func _confirm(_ row: AdminBrokenBuild) { let version = _version.trimmingCharacters(in: .whitespacesAndNewlines) let note = _note.trimmingCharacters(in: .whitespacesAndNewlines) _run { try await session.act( .put, "/api/reviews/broken", body: AdminRequest.BrokenBuildDecision( app: row.appKey, version: version, note: note.isEmpty ? nil : note ) ) } onSuccess: { _editing = nil } } private func _clear(_ row: AdminBrokenBuild) { _run { try await session.act( .put, "/api/reviews/broken", body: AdminRequest.BrokenBuildDecision(app: row.appKey, clear: true) ) } } private func _run( _ operation: @escaping @MainActor () async throws -> AdminAck, onSuccess: (@MainActor () -> Void)? = nil ) { _busy = true Task { @MainActor in do { let ack = try await operation() _banner = ack.summary onSuccess?() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } } }