6de2455917
- 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
485 lines
16 KiB
Swift
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)
|
|
}
|