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) }