Files
Felitendo cff4de60a8 feat: ask a tweak both questions
A tweak listing asked how good the tweak is and nothing else. The other
thing worth knowing about one is whether the build being served actually
launches, and that is not something stars can carry: a tweak worth five
of them can still ship a version that does not start, and that is the
half somebody about to install reads first.

So a review of a tweak now carries a thumb beside its stars. The two
answer different questions and are kept apart everywhere: the stars are
about the tweak, the thumb is about the build the reviewer had, the
listing publishes both counts separately, and the card and both consoles
draw them side by side. On a plain listing nothing changes, because
there is nothing there to rate.

The thumb is optional beside a rating and required without one. A review
of a tweak is about the tweak, and refusing to take one because somebody
had no opinion on today's build would cost the review to gain nothing.

Two things had to move for this. The rating is now what says which scale
a review is on, because the thumb no longer can. And asking for a build
to be looked at goes back to one rule for both scales, hanging off a
thumbs down: a tweak has a thumb of its own now, so the special case
added for it last time is gone. The revision history gained a column for
the thumb, which it needs and used to fake by storing a five or a one in
the rating slot.

The store row still prints one number. Three of them beside a version
and a description is a row nobody reads, and whether the build runs is
on the app's own page where there is room to say which of the two it is.
2026-09-02 14:32:56 +02:00

541 lines
15 KiB
Swift

//
// AdminReviewsView.swift
// FTStore Admin
//
// Mirrors /admin/reviews: the ratings and reviews people wrote about apps in
// the store.
//
// Two questions, two modes. "Reviews" is what came in, newest first, which is
// the daily read and the one with work in it. "By app" is the catalog side:
// what has been reviewed and how it scored, which is what an operator opens
// before deciding whether a build is worth keeping.
//
// This screen is triage. Every decision is made in the review behind it.
//
import SwiftUI
/// Which of the two lists is on screen.
enum AdminReviewMode: String, CaseIterable, Identifiable {
case reviews
case apps
var id: String { rawValue }
var label: String {
switch self {
case .reviews: return .localized("Reviews")
case .apps: return .localized("By app")
}
}
}
/// Stars, drawn the way the store draws them.
///
/// Halves are drawn because an average is a decimal and rounding 4.4 up to five
/// full stars overstates it. A single review's own rating never lands on a half,
/// so this is one view for both.
struct AdminStars: View {
let value: Double
var size: CGFloat = 12
var body: some View {
let rounded = (value * 2).rounded() / 2
HStack(spacing: 1) {
ForEach(1...5, id: \.self) { star in
Image(systemName: _symbol(star, rounded: rounded))
.font(.system(size: size))
.foregroundStyle(
Double(star) - 0.5 <= rounded ? Color.orange : Color.secondary.opacity(0.4)
)
}
}
.accessibilityElement()
.accessibilityLabel(Text(verbatim: .localized("%@ out of 5", AdminFormat.rating(value))))
}
private func _symbol(_ star: Int, rounded: Double) -> String {
if Double(star) <= rounded { return "star.fill" }
if Double(star) - 0.5 <= rounded { return "star.leadinghalf.filled" }
return "star"
}
}
/// The average, its stars, and how many reviews it is of.
struct AdminRatingLine: View {
let average: Double?
let count: Int
var size: CGFloat = 12
var body: some View {
if count == 0 || average == nil {
Text("No reviews yet")
.font(.caption)
.foregroundStyle(.secondary)
} else {
HStack(spacing: 6) {
AdminStars(value: average ?? 0, size: size)
Text(verbatim: "\(AdminFormat.rating(average ?? 0)) · \(AdminFormat.count(count))")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
}
/// The thumb equivalent: the share that says a build works, and of how many.
///
/// A percentage rather than a bar, because the question behind it is "will this
/// run" and that is a probability. The count is beside it because two out of two
/// is also 100%.
struct AdminWorksLine: View {
let ratio: Double?
let count: Int
var body: some View {
if count == 0 || ratio == nil {
Text("Nobody has said yet")
.font(.caption)
.foregroundStyle(.secondary)
} else {
let share = ratio ?? 0
HStack(spacing: 6) {
Image(systemName: "hand.thumbsup.fill")
.font(.caption2)
Text(verbatim: "\(Int((share * 100).rounded()))% · \(AdminFormat.count(count))")
.font(.caption.monospacedDigit())
}
.foregroundStyle(share >= 0.75 ? Color.green : share >= 0.4 ? Color.orange : Color.red)
}
}
}
/// The five-bar breakdown the App Store puts beside the average.
///
/// Bars are proportional to the largest bucket rather than to the total, so a
/// distribution is still readable on an app with four reviews. The counts are
/// printed too, because a bar chart of single digits says less than the digits.
struct AdminRatingHistogram: View {
let buckets: [Int: Int]
let count: Int
var body: some View {
let peak = max(1, buckets.values.max() ?? 1)
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(stride(from: 5, through: 1, by: -1)), id: \.self) { star in
HStack(spacing: 6) {
Text(verbatim: "\(star)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 10, alignment: .trailing)
Image(systemName: "star.fill")
.font(.system(size: 8))
.foregroundStyle(.orange)
GeometryReader { geometry in
ZStack(alignment: .leading) {
Capsule()
.fill(Color(uiColor: .tertiarySystemFill))
Capsule()
.fill(Color.orange)
.frame(
width: geometry.size.width
* CGFloat(buckets[star] ?? 0)
/ CGFloat(peak)
)
}
}
.frame(height: 5)
Text(verbatim: AdminFormat.count(buckets[star] ?? 0))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 34, alignment: .trailing)
}
}
}
}
}
struct AdminReviewsView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _reviews = AdminLoader<AdminReviewListResponse>()
@StateObject private var _apps = AdminLoader<AdminReviewedAppsResponse>()
@State private var _mode = AdminReviewMode.reviews
@State private var _search = ""
/// `nil` is "any", which is the useful default: an operator opens this to see
/// what came in, not to see one star band.
@State private var _rating: Int?
/// Unanswered first is what the screen is usually opened for, but it is a
/// filter rather than a sort: folding it into the order would reshuffle the
/// list every time an answer was written.
@State private var _answered: String?
@State private var _appSort = "reviews"
/// Set by tapping an app, which narrows the queue to that listing rather than
/// opening a screen of its own. A second screen would be this one with one
/// filter set, and two of those drift apart.
@State private var _appKey: String?
private var _allReviews: [AdminReview] {
let rows = _reviews.value?.data ?? []
let query = _search.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return rows }
return rows.filter { $0.matches(query) }
}
private var _allApps: [AdminReviewedApp] {
let rows = _apps.value?.data ?? []
let query = _search.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return rows }
return rows.filter { $0.matches(query) }
}
private var _stats: AdminReviewListResponse.Stats? { _reviews.value?.stats }
var body: some View {
List {
if let stats = _stats {
_statsSection(stats)
}
Section {
Picker("Show", selection: $_mode) {
ForEach(AdminReviewMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
if _mode == .reviews {
Picker("Rating", selection: $_rating) {
Text("Any rating").tag(Int?.none)
ForEach(Array(stride(from: 5, through: 1, by: -1)), id: \.self) { star in
Text(verbatim: star == 1
? .localized("1 star")
: .localized("%lld stars", star)
).tag(Int?.some(star))
}
}
Picker("Answered", selection: $_answered) {
Text("Answered or not").tag(String?.none)
Text("Not answered").tag(String?.some("no"))
Text("Answered").tag(String?.some("yes"))
}
} else {
Picker("Sort", selection: $_appSort) {
Text("Most reviewed").tag("reviews")
Text("Best rated").tag("rating")
Text("Worst rated").tag("worst")
Text("Recently reviewed").tag("recent")
}
}
}
if let appKey = _appKey, _mode == .reviews {
Section {
Button {
_appKey = nil
Task { await _reload() }
} label: {
Label("Show every app", systemImage: "xmark.circle")
}
} header: {
Text(verbatim: appKey)
}
}
// Its own screen rather than a third segment: a broken report is not a
// review to moderate, it is a question waiting on an answer, and the
// answer is a different control from hide or delete.
Section {
NavigationLink {
AdminBrokenBuildsView()
.environmentObject(session)
} label: {
Label("Broken builds", systemImage: "exclamationmark.octagon")
}
}
if _mode == .reviews {
_reviewRows
} else {
_appRows
}
}
.navigationTitle("Reviews")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $_search, prompt: Text("Search reviews"))
.refreshable { await _reload() }
.task { await _reload() }
.onChange(of: _mode) { _ in Task { await _reload() } }
.onChange(of: _rating) { _ in Task { await _reload() } }
.onChange(of: _answered) { _ in Task { await _reload() } }
.onChange(of: _appSort) { _ in Task { await _reload() } }
}
// MARK: Stats
@ViewBuilder
private func _statsSection(_ stats: AdminReviewListResponse.Stats) -> some View {
Section {
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)],
spacing: 10
) {
AdminKPITile(
title: "Average",
value: stats.average.map { AdminFormat.rating($0) } ?? "—",
// The star listings only. A share of thumbs and a mean out of
// five are not one number, and folding them together would be
// arithmetic on two different questions.
detail: "rated listings",
icon: "star.fill",
tint: .orange
)
AdminKPITile(
title: "Reviews",
value: AdminFormat.count(stats.count),
detail: "in the store",
icon: "text.bubble"
)
AdminKPITile(
title: "Unanswered",
value: AdminFormat.count(stats.unanswered),
detail: "waiting on FTStore",
icon: "bubble.left.and.exclamationmark.bubble.right",
tint: stats.unanswered > 0 ? .orange : AdminTheme.brand
)
AdminKPITile(
title: "Apps",
value: AdminFormat.count(stats.apps),
detail: "reviewed at least once",
icon: "square.grid.2x2"
)
}
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
.listRowBackground(Color.clear)
if stats.count > 0 {
AdminRatingHistogram(
buckets: Dictionary(
uniqueKeysWithValues: (1...5).map { ($0, stats.bucket($0)) }
),
count: stats.count
)
.listRowBackground(Color.clear)
}
}
}
// MARK: Reviews
@ViewBuilder
private var _reviewRows: some View {
if _reviews.isLoading && !_reviews.hasLoaded {
AdminLoadingRow()
} else if let error = _reviews.errorMessage, !_reviews.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if _allReviews.isEmpty {
if _search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AdminEmptyRow(
icon: "star",
title: "Nothing reviewed yet",
detail: "Ratings and reviews written in the FTStore app land here."
)
} else {
AdminEmptyRow(
icon: "magnifyingglass",
title: "No matches",
detail: "No review matches that."
)
}
} else {
Section {
ForEach(_allReviews) { review in
NavigationLink {
AdminReviewDetailView(reviewId: review.id, title: review.headline) {
await _reload()
}
} label: {
_reviewRow(review)
}
}
} footer: {
Text("Tap a review to answer it, fix it or take it down.")
}
}
}
private func _reviewRow(_ review: AdminReview) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
if review.isThumb {
Label(
review.works == true ? "Works" : "Doesn't work",
systemImage: review.works == true
? "hand.thumbsup.fill"
: "hand.thumbsdown.fill"
)
.font(.caption.weight(.medium))
.foregroundStyle(review.works == true ? Color.green : Color.red)
} else {
AdminStars(value: Double(review.rating ?? 0))
// The rated listing's second answer, when its author gave
// one. The stars are about the tweak, this is about the
// build they had, and it is what a broken-build report
// hangs off.
if let works = review.works {
Image(systemName: works ? "hand.thumbsup.fill" : "hand.thumbsdown.fill")
.font(.caption)
.foregroundStyle(works ? Color.green : Color.red)
}
}
Text(verbatim: review.headline)
.font(.subheadline.weight(.medium))
.strikethrough(review.isWithdrawn)
.lineLimit(2)
}
Text(verbatim: review.appLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 6) {
Text(verbatim: "#\(review.id) · \(review.authorName)")
.font(.caption2)
.foregroundStyle(.tertiary)
if review.reportedBroken {
Label("Asked for a check", systemImage: "exclamationmark.triangle")
.font(.caption2)
.foregroundStyle(.orange)
}
if review.isAnswered {
Label("Answered", systemImage: "checkmark.bubble")
.font(.caption2)
.foregroundStyle(.green)
} else {
Label("Not answered", systemImage: "bubble")
.font(.caption2)
.foregroundStyle(.orange)
}
if review.hidden {
Label("Out of the store", systemImage: "eye.slash")
.font(.caption2)
.foregroundStyle(.red)
}
if review.isWithdrawn {
Label("Withdrawn", systemImage: "trash")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if let created = review.createdAt {
Text(verbatim: AdminFormat.relative(created))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 2)
.opacity(review.hidden || review.isWithdrawn ? 0.6 : 1)
}
// MARK: Apps
@ViewBuilder
private var _appRows: some View {
if _apps.isLoading && !_apps.hasLoaded {
AdminLoadingRow()
} else if let error = _apps.errorMessage, !_apps.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if _allApps.isEmpty {
AdminEmptyRow(
icon: "star",
title: "Nothing reviewed yet",
detail: "Once somebody rates an app in FTStore, it shows up here."
)
} else {
Section {
ForEach(_allApps) { app in
Button {
_appKey = app.appKey
_mode = .reviews
} label: {
_appRow(app)
}
.buttonStyle(.plain)
}
} footer: {
Text("Tap an app to see only its reviews.")
}
}
}
private func _appRow(_ app: AdminReviewedApp) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(app.appName)
.font(.subheadline.weight(.medium))
.lineLimit(1)
// The key rather than the bundle id: two tweaks of one app share a
// bundle id, and this is what tells the reader which is which.
Text(verbatim: app.appKey)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: 10) {
if app.isThumb {
AdminWorksLine(ratio: app.ratio, count: app.count)
} else {
AdminRatingLine(average: app.average, count: app.count)
}
if app.unanswered > 0 {
Label(AdminFormat.count(app.unanswered), systemImage: "bubble")
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
.padding(.vertical, 2)
}
// MARK: Actions
private func _reload() async {
if _mode == .apps {
await _apps.load {
try await session.get(
"/api/reviews/apps",
query: ["sort": _appSort],
as: AdminReviewedAppsResponse.self
)
}
return
}
var query: [String: String] = ["pageSize": "100", "sort": "recent"]
if let rating = _rating { query["rating"] = String(rating) }
if let answered = _answered { query["answered"] = answered }
if let appKey = _appKey { query["app"] = appKey }
await _reviews.load {
try await session.get(
"/api/reviews",
query: query,
as: AdminReviewListResponse.self
)
}
}
}