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.
442 lines
12 KiB
Swift
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")"
|
|
}
|
|
}
|