// // 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: ObservableObject { @Published private(set) var value: Value? @Published private(set) var errorMessage: String? @Published private(set) var isLoading = false private var _task: Task? 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")" } }