// // AdminRootView.swift // FTStore Admin // // The app's front door and FTStore Admin's table of contents. Everything below // it assumes an authenticated session — this view is what guarantees that. // import SwiftUI struct AdminRootView: View { @EnvironmentObject private var session: AdminSession var body: some View { Group { if session.isRestoring { VStack(spacing: 12) { ProgressView() Text("Restoring session…") .font(.footnote) .foregroundStyle(.secondary) } } else if session.user == nil { AdminLoginView() } else if session.isLocked { AdminLockedView() } else { AdminHomeView() } } .tint(AdminTheme.brand) .task { guard session.isRestoring else { return } await session.restore() } } } // MARK: - Home struct AdminHomeView: View { @EnvironmentObject private var session: AdminSession @Environment(\.scenePhase) private var _scenePhase @AppStorage(AdminAlertWatcher.storageKey) private var _alertStorage = false @AppStorage(AdminAlertWatcher.feedbackKey) private var _alertFeedback = false @AppStorage(AdminAlertWatcher.requestsKey) private var _alertRequests = false @AppStorage(AdminAlertWatcher.accessKey) private var _alertAccess = false @StateObject private var _status = AdminLoader>() @State private var _systemBusy = false @State private var _signingOut = false private var _data: AdminDashboardStatus? { _status.value?.data } var body: some View { NavigationStack { List { _accountSection _systemSection _summarySection _manageSection _alertsSection _securitySection _aboutSection } .navigationTitle("FTStore Admin") .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .onChange(of: _scenePhase) { phase in guard phase == .active else { return } Task { await _reload() } } } } // MARK: Sections private var _accountSection: some View { Section { HStack(spacing: 12) { Image(systemName: session.isAdmin ? "person.badge.key.fill" : "person.crop.circle.fill") .font(.title2) .foregroundStyle(AdminTheme.brand) VStack(alignment: .leading, spacing: 2) { Text(session.user?.username ?? "—") .font(.subheadline.weight(.semibold)) Text("\(String.localized(session.isAdmin ? "Admin" : "User")) · \(session.serverDescription)") .font(.caption) .foregroundStyle(.secondary) } Spacer() } if let warning = session.connectionWarning { Label(warning, systemImage: "wifi.exclamationmark") .font(.caption) .foregroundStyle(.orange) } // Here rather than buried in Setup: forgetting the dashboard password // while still signed in on this app is exactly the situation this // screen exists for, and somebody in it is not going to go looking // three levels down for it. NavigationLink { AdminPasswordView() .environmentObject(session) } label: { Label("Change password", systemImage: "key") } } } private var _systemSection: some View { Section { HStack(spacing: 10) { Circle() .fill(_data?.isRunning == true ? Color.green : Color.red) .frame(width: 9, height: 9) VStack(alignment: .leading, spacing: 2) { Text(_data?.isRunning == true ? "System Online" : "System Offline") .font(.subheadline.weight(.medium)) if let uptime = _data?.uptime { Text("Up \(AdminFormat.duration(uptime))") .font(.caption) .foregroundStyle(.secondary) } } Spacer() if _systemBusy { ProgressView() } else if let running = _data?.isRunning { Button(running ? "Stop" : "Start") { _toggleSystem(to: !running) } .font(.subheadline.weight(.semibold)) .tint(running ? .red : .green) } } } header: { Text("Worker") } footer: { Text("Stopping the worker halts scanning, downloads and feed generation until it is started again.") } } @ViewBuilder private var _summarySection: some View { Section { if let data = _data { LazyVGrid( columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10 ) { AdminKPITile( title: "Builds", value: AdminFormat.count(data.totalIpas), detail: "in the feed", icon: "shippingbox.fill" ) AdminKPITile( title: "Downloads", value: AdminFormat.count(data.totalDownloads), detail: "all time", icon: "arrow.down.circle.fill" ) AdminKPITile( title: "Storage", value: AdminFormat.bytes(data.storageUsed), detail: data.storageIsCapped ? "of \(AdminFormat.bytes(data.storageLimitBytes))" : "no limit", icon: "internaldrive.fill", tint: data.storageOverLimit ? .red : AdminTheme.brand ) AdminKPITile( title: "Queue", value: AdminFormat.count(data.queueDepth), detail: "pending", icon: "list.bullet.rectangle.fill", tint: data.queueDepth > 0 ? .orange : AdminTheme.brand ) } .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) .listRowBackground(Color.clear) } else if let error = _status.errorMessage { AdminErrorRow(message: error) { Task { await _reload() } } } else { AdminLoadingRow() } } } private var _manageSection: some View { Section("Manage") { NavigationLink { AdminDashboardView() } label: { Label("Dashboard", systemImage: "chart.bar.xaxis") } NavigationLink { AdminLibraryView() } label: { Label("Library", systemImage: "shippingbox") } NavigationLink { AdminQueueView() } label: { Label("Queue", systemImage: "list.bullet.rectangle") } NavigationLink { AdminTweakRequestsView() } label: { Label("Tweak Requests", systemImage: "tray.and.arrow.down") } NavigationLink { AdminFeedbackView() } label: { Label("Feedback", systemImage: "bubble.left.and.bubble.right") } // Next to Feedback, and its own row rather than a filter inside it: // both are things people wrote in the open, but a report is about // FTStore and a review is about one app in it, and the two are // answered from different screens. NavigationLink { AdminReviewsView() } label: { Label("Reviews", systemImage: "star.bubble") } // The people who write all of the above: what they wrote, and // whether they still may. Deliberately not the same list as Users // under Setup & Diagnostics, which is who may sign in and run the // server — a delete button means something very different on each, // which is why they are two screens and now two names. NavigationLink { AdminFeedbackUsersView() } label: { Label("Users", systemImage: "person.2.circle") } // Everything else is one row down. The five above are what an // operator opens on an ordinary day — what arrived, what is // downloading, what people are asking for. The rest is how the // server is set up and what it has been doing, which is looked at // when something needs changing or explaining, not daily. NavigationLink { AdminMoreView() } label: { Label("Setup & Diagnostics", systemImage: "ellipsis.circle") } } } /// What FTStore Admin is allowed to interrupt somebody about. /// /// Four switches rather than one. An operator who wants to know the disk is /// filling up does not necessarily want a banner for every feature request, /// and a single toggle would make them take both or neither. /// /// Permission is asked for when the first one goes on, not at launch: an app /// that asks before it has anything to say is one people say no to. private var _alertsSection: some View { Section { Toggle(isOn: $_alertStorage) { Label("Storage warnings", systemImage: "externaldrive.badge.exclamationmark") } Toggle(isOn: $_alertFeedback) { Label("New feedback", systemImage: "bubble.left.and.bubble.right") } Toggle(isOn: $_alertRequests) { Label("New tweak requests", systemImage: "tray.and.arrow.down") } Toggle(isOn: $_alertAccess) { Label("New device or country", systemImage: "lock.shield") } } header: { Text("Notifications") } footer: { Text("Storage warns once at 80% of the configured limit. The device alert only has anything to say while private mode is on. These are local notifications, so FTStore Admin can only post them while it is open or while iOS grants it a background refresh, it is a nudge rather than a pager.") } .onChange(of: _alertStorage) { on in _armed(on) } .onChange(of: _alertFeedback) { on in _armed(on) } .onChange(of: _alertRequests) { on in _armed(on) } .onChange(of: _alertAccess) { on in _armed(on) } } private func _armed(_ on: Bool) { guard on else { return } AdminAlertWatcher.requestAuthorization() Task { await AdminAlertWatcher.shared.check(session: session) } } private var _securitySection: some View { Section { Toggle(isOn: $session.requireBiometrics) { Label("Require Face ID / Passcode", systemImage: "faceid") } if session.requireBiometrics { Button { session.lock() } label: { Label("Lock FTStore Admin", systemImage: "lock") } } Button(role: .destructive) { _signingOut = true Task { await session.signOut() _signingOut = false } } label: { HStack { Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") if _signingOut { Spacer() ProgressView() } } } .disabled(_signingOut) } header: { Text("Security") } footer: { Text("Signing out revokes this device's session on the server. Installing and updating FTStore is FTStore Manager's job and never needs an account.") } } private var _aboutSection: some View { Section { LabeledContent("Version", value: AdminConfig.appVersion) LabeledContent("Server", value: session.serverDescription) } } // MARK: Actions private func _reload() async { await _status.load { try await session.get("/api/dashboard/status", as: AdminEnvelope.self) } } private func _toggleSystem(to enabled: Bool) { _systemBusy = true Task { @MainActor in _ = try? await session.send( .post, "/api/system", body: AdminRequest.Enabled(enabled: enabled), as: AdminSystemState.self ) await _reload() _systemBusy = false } } }