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.
541 lines
15 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|