A store listing is one of two things and the review control now follows which. A plain build is somebody's signed copy of an app that already exists. There is nothing there to have an opinion about, and five stars for it says nothing about that copy: the one open question is whether it installs and runs, which is a yes or a no. Those listings take a thumb, with an optional line for what went wrong, and the store prints the share of people who got it working. A tweak is somebody's work. It adds things, breaks things, and how well is exactly what a rating out of five is for. Those keep the stars, and the composer now says by name that the stars are for the tweak and not for the app underneath it. Without that sentence a five-star scale under a listing named after both reads as a scale for the app, and a store full of opinions about the app tells nobody anything about the tweak. Which of the two a listing takes is decided by its key and published with it, so the rule lives on the server and the clients read it rather than working it out again. The two scores are separate columns, exactly one of them is ever set, and the summary carries both halves in one shape with a mode saying which to read. A thumbs down can also ask for the build to be checked. That is a request and nothing else: nobody sees it but an operator, because a flag anyone could raise from the app is a way for anyone to put a warning on somebody else's build. Confirming one names a version, and that is what turns one decision into two published states: that version reads as broken, anything newer reads as possibly broken because nobody has looked at it, and clearing takes both off at once. A build older than the confirmed one gets nothing, since a warning on something nobody complained about is a warning people learn to ignore. None of this is the corrupted flag, and the difference is what each one costs. Corrupted says the file is bad and the download is refused. This says the build installs and runs and the thing inside it does not work, which only somebody who ran it can know, so it is a warning and the install button stays where it is.
297 lines
8.4 KiB
Swift
297 lines
8.4 KiB
Swift
//
|
|
// 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<AdminBrokenBuildsResponse>()
|
|
|
|
@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()
|
|
}
|
|
}
|
|
}
|