Files
boc/aamos-admin-upgrade/ios/AamosAdmin/Views/ModulesView.swift
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

485 lines
16 KiB
Swift

import SwiftUI
// MARK: - Models
struct AamosModule: Identifiable, Equatable {
let id: String
let name: String
let description: String
let icon: String
var isEnabled: Bool
let category: ModuleCategory
enum ModuleCategory: String, CaseIterable {
case core = "Core"
case research = "Research"
case comms = "Comms"
case monitoring = "Monitoring"
}
}
enum ModuleLoadState {
case idle
case toggling
case error(String)
}
// MARK: - ViewModel
@MainActor
final class ModulesViewModel: ObservableObject {
@Published var modules: [AamosModule] = []
@Published var loadStates: [String: ModuleLoadState] = [:]
@Published var isLoadingAll = false
@Published var globalError: String? = nil
private let service: ModuleService
init(service: ModuleService = .shared) {
self.service = service
}
func loadModules() async {
isLoadingAll = true
globalError = nil
do {
modules = try await service.fetchModules()
} catch {
globalError = error.localizedDescription
}
isLoadingAll = false
}
func toggle(module: AamosModule) async {
let newValue = !module.isEnabled
guard case .idle = loadStates[module.id] ?? .idle else { return }
// Optimistic update
loadStates[module.id] = .toggling
if let idx = modules.firstIndex(where: { $0.id == module.id }) {
modules[idx].isEnabled = newValue
}
do {
let updated = try await service.setEnabled(id: module.id, enabled: newValue)
if let idx = modules.firstIndex(where: { $0.id == module.id }) {
modules[idx] = updated
}
loadStates[module.id] = .idle
} catch {
// Rollback
if let idx = modules.firstIndex(where: { $0.id == module.id }) {
modules[idx].isEnabled = !newValue
}
loadStates[module.id] = .error(error.localizedDescription)
// Auto-clear error after 3s
try? await Task.sleep(for: .seconds(3))
if case .error = loadStates[module.id] ?? .idle {
loadStates[module.id] = .idle
}
}
}
var grouped: [(AamosModule.ModuleCategory, [AamosModule])] {
AamosModule.ModuleCategory.allCases.compactMap { cat in
let items = modules.filter { $0.category == cat }
return items.isEmpty ? nil : (cat, items)
}
}
}
// MARK: - Service
final class ModuleService {
static let shared = ModuleService()
private init() {}
func fetchModules() async throws -> [AamosModule] {
try await Task.sleep(for: .milliseconds(400))
return AamosModule.mock
}
func setEnabled(id: String, enabled: Bool) async throws -> AamosModule {
try await Task.sleep(for: .milliseconds(600))
guard var module = AamosModule.mock.first(where: { $0.id == id }) else {
throw URLError(.badServerResponse)
}
module.isEnabled = enabled
return module
}
}
// MARK: - Mock data
extension AamosModule {
static let mock: [AamosModule] = [
AamosModule(id: "watchman", name: "Watchman", description: "Multi-source intelligence pipeline with dual-LLM synthesis", icon: "🔭", isEnabled: true, category: .research),
AamosModule(id: "digests", name: "Digests", description: "Daily watchman digest generation and delivery", icon: "📰", isEnabled: true, category: .research),
AamosModule(id: "ab-trainer", name: "AB Trainer", description: "Autonomous behavior training loop", icon: "🧠", isEnabled: false, category: .core),
AamosModule(id: "heartbeat", name: "Heartbeat", description: "System health monitor and cron orchestrator", icon: "💓", isEnabled: true, category: .monitoring),
AamosModule(id: "mail", name: "Mail", description: "Inbound mail parsing and action routing", icon: "📬", isEnabled: true, category: .comms),
AamosModule(id: "build-monitor", name: "Build Monitor", description: "CI/CD build status watcher with Slack alerts", icon: "🏗️", isEnabled: false, category: .monitoring),
AamosModule(id: "workers", name: "Workers", description: "Background task worker pool management", icon: "⚙️", isEnabled: true, category: .core),
AamosModule(id: "amos-call", name: "Amos Call", description: "Scheduled outbound agent invocation", icon: "📞", isEnabled: false, category: .comms),
]
}
// MARK: - Root view
struct ModulesView: View {
@StateObject private var vm = ModulesViewModel()
private let columns = [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
]
var body: some View {
NavigationStack {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
if vm.isLoadingAll {
ProgressView("Laddar moduler…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let err = vm.globalError {
GlobalErrorView(message: err) {
Task { await vm.loadModules() }
}
} else {
ScrollView {
LazyVStack(spacing: 24, pinnedViews: .sectionHeaders) {
ForEach(vm.grouped, id: \.0) { category, items in
Section {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(items) { module in
ModuleCard(
module: module,
loadState: vm.loadStates[module.id] ?? .idle
) {
Task { await vm.toggle(module: module) }
}
}
}
.padding(.horizontal, 16)
} header: {
SectionHeader(title: category.rawValue)
}
}
}
.padding(.vertical, 16)
}
.refreshable {
await vm.loadModules()
}
}
}
.navigationTitle("Moduler")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ModuleSummaryBadge(
active: vm.modules.filter(\.isEnabled).count,
total: vm.modules.count
)
}
}
}
.task {
await vm.loadModules()
}
}
}
// MARK: - Section header
private struct SectionHeader: View {
let title: String
var body: some View {
HStack {
Text(title.uppercased())
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(Color(.secondaryLabel))
.padding(.horizontal, 20)
.padding(.vertical, 6)
Spacer()
}
.background(Color(.systemGroupedBackground))
}
}
// MARK: - Module card
struct ModuleCard: View {
let module: AamosModule
let loadState: ModuleLoadState
let onToggle: () -> Void
@State private var isPressed = false
private var isToggling: Bool {
if case .toggling = loadState { return true }
return false
}
private var errorMessage: String? {
if case .error(let msg) = loadState { return msg }
return nil
}
var body: some View {
ZStack(alignment: .topTrailing) {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top) {
Text(module.icon)
.font(.system(size: 36))
.frame(width: 44, height: 44)
Spacer()
StatusBadge(isEnabled: module.isEnabled)
}
VStack(alignment: .leading, spacing: 4) {
Text(module.name)
.font(.system(.subheadline, design: .default, weight: .semibold))
.foregroundStyle(Color(.label))
.lineLimit(1)
Text(module.description)
.font(.caption)
.foregroundStyle(Color(.secondaryLabel))
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 4)
Divider()
HStack {
if isToggling {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(0.75)
.frame(width: 20, height: 20)
Text(module.isEnabled ? "Aktiverar…" : "Inaktiverar…")
.font(.caption2)
.foregroundStyle(Color(.tertiaryLabel))
} else {
Text(module.isEnabled ? "Aktiv" : "Inaktiv")
.font(.caption2)
.foregroundStyle(Color(.tertiaryLabel))
}
Spacer()
Toggle("", isOn: Binding(
get: { module.isEnabled },
set: { _ in
guard !isToggling else { return }
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
onToggle()
}
))
.labelsHidden()
.tint(Color(.systemGreen))
.disabled(isToggling)
.scaleEffect(0.85, anchor: .trailing)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
errorMessage != nil
? Color(.systemRed).opacity(0.6)
: Color(.separator).opacity(0.4),
lineWidth: errorMessage != nil ? 1.5 : 0.5
)
)
.shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 2)
.scaleEffect(isPressed ? 0.97 : 1.0)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: isPressed)
.animation(.easeInOut(duration: 0.2), value: module.isEnabled)
if let msg = errorMessage {
ErrorTooltip(message: msg)
.offset(x: -8, y: -8)
.transition(.scale.combined(with: .opacity))
.animation(.spring(response: 0.3, dampingFraction: 0.75), value: errorMessage != nil)
.zIndex(1)
}
}
._onButtonGesture(pressing: { pressed in
isPressed = pressed
}, perform: {})
}
}
// MARK: - Status badge
private struct StatusBadge: View {
let isEnabled: Bool
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(isEnabled ? Color(.systemGreen) : Color(.systemGray3))
.frame(width: 6, height: 6)
.shadow(color: isEnabled ? Color(.systemGreen).opacity(0.5) : .clear,
radius: 3, x: 0, y: 0)
Text(isEnabled ? "ON" : "OFF")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(isEnabled ? Color(.systemGreen) : Color(.systemGray))
}
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(
Capsule()
.fill(isEnabled
? Color(.systemGreen).opacity(0.12)
: Color(.systemGray5))
)
.animation(.easeInOut(duration: 0.2), value: isEnabled)
}
}
// MARK: - Error tooltip
private struct ErrorTooltip: View {
let message: String
var body: some View {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10))
.foregroundStyle(Color(.systemRed))
Text("Fel")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Color(.systemRed))
}
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(
Capsule()
.fill(Color(.systemRed).opacity(0.12))
.overlay(
Capsule()
.strokeBorder(Color(.systemRed).opacity(0.3), lineWidth: 0.5)
)
)
}
}
// MARK: - Toolbar summary badge
private struct ModuleSummaryBadge: View {
let active: Int
let total: Int
var body: some View {
HStack(spacing: 4) {
Image(systemName: "bolt.fill")
.font(.caption2)
.foregroundStyle(Color(.systemOrange))
Text("\(active)/\(total)")
.font(.system(.caption, design: .monospaced, weight: .semibold))
.foregroundStyle(Color(.label))
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(Color(.secondarySystemBackground))
.shadow(color: .black.opacity(0.06), radius: 3, x: 0, y: 1)
)
}
}
// MARK: - Global error view
private struct GlobalErrorView: View {
let message: String
let retry: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 44))
.foregroundStyle(Color(.systemOrange))
VStack(spacing: 4) {
Text("Kunde inte ladda moduler")
.font(.headline)
Text(message)
.font(.caption)
.foregroundStyle(Color(.secondaryLabel))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Button(action: retry) {
Label("Försök igen", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.systemBlue))
)
.foregroundStyle(.white)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Preview
#Preview("Moduler") {
ModulesView()
}
#Preview("Module Card — aktiv") {
ModuleCard(
module: AamosModule.mock[0],
loadState: .idle,
onToggle: {}
)
.padding()
.background(Color(.systemGroupedBackground))
.previewLayout(.sizeThatFits)
}
#Preview("Module Card — laddar") {
ModuleCard(
module: AamosModule.mock[0],
loadState: .toggling,
onToggle: {}
)
.padding()
.background(Color(.systemGroupedBackground))
.previewLayout(.sizeThatFits)
}
#Preview("Module Card — fel") {
ModuleCard(
module: AamosModule.mock[2],
loadState: .error("Timeout efter 30s"),
onToggle: {}
)
.padding()
.background(Color(.systemGroupedBackground))
.previewLayout(.sizeThatFits)
}