Files
Felitendo 2a5d83002a feat: let people review the apps in the store
A star rating, a headline and a few sentences about one app, shown on that
app's page the way a store shows them: the average with its breakdown, a row
of reviews to read, and one answer from FTStore underneath any of them.
Registered accounts only, because a rating is a number printed under an app's
name and an unsigned one is a number anybody can mint as often as they like.

The identity a review hangs on is the feed's own grouping key rather than the
bundle id, and that is the whole design. Three tweaks of one app are three
listings sharing one bundle id, so keying on that would pool them into one
rating and print a verdict about somebody else's build under yours. The feed
now publishes that key on every listing, an alias folds onto the primary name
on the way in so an older client lands on the same rows, and a tweak name the
list no longer knows is kept as it came rather than merged into the untweaked
app. Only our own feed publishes a key, so a third-party source's apps get no
reviews section rather than an empty one.

One review per account per app, as a real unique index, which makes writing and
editing the same call. Every edit keeps what it said before, the rating
included: a change of stars with the words left alone is the most consequential
edit here and the one a text diff records as nothing at all. Withdrawing is
soft, so an answer cannot be provoked and then have the line it answered
removed, and writing again revives the row rather than adding a second.

Moderation covers both consoles. A review can be edited, answered, taken out of
the store, or deleted outright; a takedown leaves the average as well as the
list, is reversible, and is visible to the person it happened to. Editing
somebody else's words is never silent: the old version goes into the history,
the store marks the review as edited by FTStore, and changing a rating asks
first because that is the one that moves a number. An answer is published as
the project and never as whoever wrote it.

Ratings are derived from the rows on every read rather than kept in counters,
so nothing can drift out of step with what is behind it.
2026-09-01 18:30:27 +02:00

442 lines
12 KiB
Swift

//
// AdminComponents.swift
// FTStore Admin
//
// Small shared pieces for the FTStore Admin's screens: the fetch-once-and-refresh
// loader every list uses, placeholder states, and the tiles/pills that make a
// 1377-line dashboard table readable on a phone.
//
// Every label these take is a `String` rather than a `LocalizedStringKey`, so
// SwiftUI would render it verbatim. They localize it themselves instead, which
// is what lets every call site keep passing a plain English literal and still
// come out German on a German phone. The exception is `AdminErrorRow.message`:
// that text comes from the server, and pretending to translate it would just
// mean showing the English sentence back with a lookup miss.
//
import SwiftUI
// MARK: - Loader
/// One async value with its loading and error state.
///
/// Written as an `ObservableObject` rather than three `@State` flags per screen
/// so a pull-to-refresh can replace an in-flight fetch without the older one
/// landing afterwards and overwriting the newer result.
@MainActor
final class AdminLoader<Value>: ObservableObject {
@Published private(set) var value: Value?
@Published private(set) var errorMessage: String?
@Published private(set) var isLoading = false
private var _task: Task<Void, Never>?
var hasLoaded: Bool { value != nil }
func load(_ operation: @escaping @MainActor () async throws -> Value) async {
_task?.cancel()
// Only show the full-screen spinner on the first load; a refresh keeps
// the previous content on screen.
isLoading = value == nil
let task = Task { @MainActor in
do {
let result = try await operation()
guard !Task.isCancelled else { return }
value = result
errorMessage = nil
} catch is CancellationError {
// Superseded by a newer load — leave the state alone.
} catch {
guard !Task.isCancelled else { return }
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
isLoading = false
}
_task = task
await task.value
}
func clear() {
_task?.cancel()
value = nil
errorMessage = nil
isLoading = false
}
}
// MARK: - Placeholders
struct AdminLoadingRow: View {
var label = "Loading…"
var body: some View {
HStack(spacing: 10) {
ProgressView()
Text(String.localized(label))
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
.listRowSeparator(.hidden)
}
}
struct AdminErrorRow: View {
let message: String
var retry: (() -> Void)?
var body: some View {
VStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title2)
.foregroundStyle(.orange)
Text(message)
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
if let retry {
Button("Try Again", action: retry)
.font(.subheadline.weight(.medium))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.listRowSeparator(.hidden)
}
}
struct AdminEmptyRow: View {
let icon: String
let title: String
var detail: String?
/// Draw a bundled asset instead of an SF Symbol. Set for the Telegram mark,
/// which is the one icon in FTStore Admin that isn't a symbol.
var assetIcon: String?
var body: some View {
VStack(spacing: 8) {
Group {
if let assetIcon {
Image(assetIcon)
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22)
} else {
Image(systemName: icon)
}
}
.font(.title2)
.foregroundStyle(.tertiary)
Text(String.localized(title))
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
if let detail {
Text(String.localized(detail))
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.listRowSeparator(.hidden)
}
}
// MARK: - Tiles
/// The dashboard KPI card, sized for a two-column grid on a phone.
struct AdminKPITile: View {
let title: String
let value: String
var detail: String?
var icon: String
var tint: Color = AdminTheme.brand
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(tint)
Text(String.localized(title))
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
// `value` is a formatted number or a byte count, never prose.
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.6)
if let detail {
Text(String.localized(detail))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(
Color(uiColor: .secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
)
}
}
/// Coloured status chip, shared by the queue, the logs and the library.
struct AdminStatusPill: View {
let status: String
var body: some View {
// The server's own vocabulary (`success`, `failed`, `downloading`, …).
// Capitalising first makes the catalog key the English word a reader
// would recognise, and an unknown status falls back to exactly what this
// used to render.
Text(String.localized(status.capitalized))
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Self.tint(for: status).opacity(0.15), in: Capsule())
.foregroundStyle(Self.tint(for: status))
}
static func tint(for status: String) -> Color {
switch status.lowercased() {
case "success", "completed", "running", "approved": return .green
case "failed", "error", "rejected": return .red
case "warning", "skipped": return .orange
case "downloading", "processing", "info": return AdminTheme.brand
default: return .secondary
}
}
}
/// A row that runs one server-side action and reports what came back.
struct AdminActionRow: View {
let title: String
let icon: String
var detail: String?
var destructive = false
/// Shown in a confirmation dialog before running; `nil` runs immediately.
var confirmation: String?
/// Main-actor isolated so an action can touch view state (dismiss a sheet,
/// trigger a parent reload) without hopping actors by hand.
let run: @MainActor () async throws -> String
@State private var _running = false
@State private var _confirming = false
@State private var _result: String?
@State private var _failed = false
var body: some View {
Button {
if confirmation != nil {
_confirming = true
} else {
_run()
}
} label: {
HStack(spacing: 12) {
Image(systemName: icon)
.frame(width: 22)
.foregroundStyle(destructive ? Color.red : AdminTheme.brand)
VStack(alignment: .leading, spacing: 2) {
Text(String.localized(title))
.foregroundStyle(destructive ? Color.red : Color.primary)
if let detail {
Text(String.localized(detail))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if _running { ProgressView() }
}
}
.disabled(_running)
.confirmationDialog(
String.localized(confirmation ?? ""),
isPresented: $_confirming,
titleVisibility: .visible
) {
Button(role: destructive ? .destructive : nil) {
_run()
} label: {
Text(String.localized(title))
}
Button("Cancel", role: .cancel) {}
}
.alert(
_failed ? String.localized("Failed") : String.localized(title),
isPresented: Binding(
get: { _result != nil },
set: { if !$0 { _result = nil } }
)
) {
Button("OK") { _result = nil }
} message: {
// Whatever the server said, verbatim.
Text(_result ?? "")
}
}
private func _run() {
_running = true
Task { @MainActor in
do {
_result = try await run()
_failed = false
} catch {
_result = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
_failed = true
}
_running = false
}
}
}
// MARK: - Camera
/// The camera half of an icon picker. SwiftUI has no camera equivalent of
/// `.photosPicker`, so this is the UIKit one — presented full screen, and only
/// offered when the device actually has a camera (a simulator does not).
struct AdminCameraPickerView: UIViewControllerRepresentable {
/// `nil` when the shot was cancelled. Dismissing is the caller's job — it
/// owns the `isPresented` flag either way.
let onCapture: (UIImage?) -> Void
static var isAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
func makeCoordinator() -> Coordinator {
Coordinator(finish: onCapture)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
// The square crop the server stores is done on our side, but letting the
// user frame it here means the icon is what they framed.
picker.allowsEditing = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIImagePickerController, context: Context) {}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private let _finish: (UIImage?) -> Void
init(finish: @escaping (UIImage?) -> Void) {
_finish = finish
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
_finish((info[.editedImage] ?? info[.originalImage]) as? UIImage)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
_finish(nil)
}
}
}
// MARK: - Formatting
enum AdminFormat {
static func bytes(_ value: Int64) -> String {
guard value > 0 else { return "0 B" }
return ByteCountFormatter.string(fromByteCount: value, countStyle: .file)
}
static func bytes(_ value: Int64?) -> String {
guard let value else { return "—" }
return bytes(value)
}
static func count(_ value: Int) -> String {
NumberFormatter.localizedString(from: NSNumber(value: value), number: .decimal)
}
/// Already locale-aware: `RelativeDateTimeFormatter` writes "vor 3 Min." on a
/// German phone without being told to.
private static let _relative: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter
}()
static func relative(_ date: Date?) -> String {
guard let date else { return .localized("never") }
return _relative.localizedString(for: date, relativeTo: Date())
}
static func timestamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
/// `uptime` comes back as seconds from `process.uptime()`. The unit letters
/// are translated rather than hardcoded, because German abbreviates them
/// differently (Std./Min./Sek.) and "3h 20m" reads as English there.
static func duration(_ seconds: Double) -> String {
let total = Int(seconds)
if total >= 86400 {
return .localized("%lldd %lldh", total / 86400, (total % 86400) / 3600)
}
if total >= 3600 {
return .localized("%lldh %lldm", total / 3600, (total % 3600) / 60)
}
if total >= 60 {
return .localized("%lldm", total / 60)
}
return .localized("%llds", total)
}
private static let _rating: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 1
formatter.maximumFractionDigits = 1
return formatter
}()
/// A star average, to one decimal, in the reader's own notation.
///
/// `NumberFormatter` rather than `String(format:)` because the separator is
/// a comma on a German phone, and a store that printed "4.3" next to prices
/// written "4,99 €" would be the only thing on the screen using a full stop.
static func rating(_ value: Double) -> String {
_rating.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
}
private static let _currency: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
return formatter
}()
/// A tweak price, in the currency the operator picked. The symbol is placed
/// the way the *reader's* locale places it, which is the same thing the
/// store does on the phone, so a price reads identically in both.
static func price(_ amount: Double, currency: String?) -> String {
_currency.currencyCode = (currency ?? "USD").uppercased()
return _currency.string(from: NSNumber(value: amount))
?? "\(amount) \(_currency.currencyCode ?? "USD")"
}
}