// // 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() @StateObject private var _apps = AdminLoader() @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 ) } } }