import SwiftUI import LabWiseKit // MARK: - Tab shell struct DashboardView: View { var body: some View { TabView { Tab("Dashboard", systemImage: "chart.bar.fill") { DashboardHomeView() } Tab("Inventory", systemImage: "flask.fill") { InventoryView() } Tab("Profile", systemImage: "person.fill") { ProfileView() } } .tint(Color(.brandPrimary)) } } // MARK: - Dashboard view model @Observable final class DashboardViewModel { var chemicals: [Chemical] = [] var isLoading = false var errorMessage: String? private let client = ChemicalsClient() // MARK: Derived stats var lowStock: [Chemical] { chemicals .filter { ($0.percentageFull ?? 100) < 20 } .sorted { ($0.percentageFull ?? 0) < ($1.percentageFull ?? 0) } } var expiringSoon: [Chemical] { chemicals .filter { c in guard let exp = c.expirationDate else { return false } return daysUntil(exp) <= 30 } .sorted { a, b in daysUntil(a.expirationDate ?? "") < daysUntil(b.expirationDate ?? "") } } var recentActivity: [Chemical] { chemicals .sorted { ($0.createdAt ?? "") > ($1.createdAt ?? "") } .prefix(4) .map { $0 } } // MARK: Load func load() async { isLoading = true errorMessage = nil defer { isLoading = false } do { chemicals = try await client.list() } catch { print("[DashboardViewModel] load error: \(error)") errorMessage = "Failed to load: \(error)" } } // MARK: Helpers func daysUntil(_ iso: String) -> Int { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate] guard let date = formatter.date(from: iso) else { return Int.max } return Int(ceil(date.timeIntervalSinceNow / 86400)) } func relativeTime(_ iso: String?) -> String { guard let iso else { return "" } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = formatter.date(from: iso) else { return "" } let seconds = Int(-date.timeIntervalSinceNow) if seconds < 60 { return "just now" } if seconds < 3600 { return "\(seconds / 60)m ago" } if seconds < 86400 { return "\(seconds / 3600)h ago" } return "\(seconds / 86400)d ago" } } // MARK: - Main dashboard view struct DashboardHomeView: View { @State private var viewModel = DashboardViewModel() var body: some View { NavigationStack { Group { if viewModel.isLoading && viewModel.chemicals.isEmpty { ProgressView("Loading...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { VStack(spacing: 20) { StatCardsRow(viewModel: viewModel) SafetyAlertsSection(viewModel: viewModel) LowStockSection(viewModel: viewModel) ExpiringSection(viewModel: viewModel) RecentActivitySection(viewModel: viewModel) } .padding() } .refreshable { await viewModel.load() } } } .navigationTitle("Dashboard") .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { Button("OK") { viewModel.errorMessage = nil } } message: { Text(viewModel.errorMessage ?? "") } } .task { await viewModel.load() } } } // MARK: - Stat cards row private struct StatCardsRow: View { let viewModel: DashboardViewModel var body: some View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { StatCard( title: "Chemicals", value: "\(viewModel.chemicals.count)", icon: "flask.fill", color: Color(.brandPrimary) ) StatCard( title: "Low Stock", value: "\(viewModel.lowStock.count)", icon: "exclamationmark.triangle.fill", color: .orange ) StatCard( title: "Expiring ≤30d", value: "\(viewModel.expiringSoon.count)", icon: "clock.fill", color: .red ) StatCard( title: "Tracked", value: viewModel.chemicals.isEmpty ? "—" : "Active", icon: "checkmark.seal.fill", color: .green ) } } } private struct StatCard: View { let title: String let value: String let icon: String let color: Color var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: icon) .foregroundStyle(color) Spacer() } Text(value) .font(.title.bold()) Text(title) .font(.caption) .foregroundStyle(.secondary) } .padding() .background(Color(UIColor.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } // MARK: - Safety alerts private struct SafetyAlertsSection: View { let viewModel: DashboardViewModel private var expired: [Chemical] { viewModel.expiringSoon.filter { viewModel.daysUntil($0.expirationDate ?? "") <= 0 } } private var lowStockAlerts: [Chemical] { Array(viewModel.lowStock.prefix(2)) } private var expiringSoonCount: Int { viewModel.expiringSoon.filter { viewModel.daysUntil($0.expirationDate ?? "") > 0 }.count } var hasAlerts: Bool { !expired.isEmpty || !lowStockAlerts.isEmpty || expiringSoonCount > 0 } var body: some View { DashboardCard(title: "Safety Alerts", icon: "shield.fill") { if !hasAlerts { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Color(.brandPrimary)) Text("All clear — no active safety alerts") .font(.subheadline) .foregroundStyle(.secondary) } .padding(.vertical, 4) } else { VStack(spacing: 8) { ForEach(expired) { chem in AlertRow( message: "\(chem.chemicalName) expired \(-viewModel.daysUntil(chem.expirationDate ?? ""))d ago — verify disposal", severity: .critical ) } ForEach(lowStockAlerts) { chem in AlertRow( message: "\(chem.chemicalName) is \(Int(chem.percentageFull ?? 0))% full — consider reordering", severity: .warning ) } if expiringSoonCount > 0 { AlertRow( message: "\(expiringSoonCount) chemical(s) expiring in the next 30 days", severity: .warning ) } } } } } } private enum AlertSeverity { case critical, warning } private struct AlertRow: View { let message: String let severity: AlertSeverity var color: Color { severity == .critical ? .red : .orange } var body: some View { HStack(alignment: .top, spacing: 10) { Circle() .fill(color) .frame(width: 8, height: 8) .padding(.top, 5) Text(message) .font(.subheadline) .foregroundStyle(Color(UIColor.label)) } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(color.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 8)) } } // MARK: - Low stock private struct LowStockSection: View { let viewModel: DashboardViewModel var body: some View { DashboardCard(title: "Low Stock", icon: "exclamationmark.triangle.fill", iconColor: .orange) { if viewModel.lowStock.isEmpty { EmptyStateRow(message: "No low stock items") } else { VStack(spacing: 0) { ForEach(viewModel.lowStock.prefix(3)) { chem in ChemAlertRow( name: chem.chemicalName, subtitle: "\(chem.lab) · \(chem.storageLocation)", badge: "\(Int(chem.percentageFull ?? 0))% full", badgeColor: .orange ) if chem.id != viewModel.lowStock.prefix(3).last?.id { Divider().padding(.leading) } } } } } } } // MARK: - Expiring soon private struct ExpiringSection: View { let viewModel: DashboardViewModel var body: some View { DashboardCard(title: "Expiring Soon", icon: "clock.fill", iconColor: .red) { if viewModel.expiringSoon.isEmpty { EmptyStateRow(message: "No chemicals expiring within 30 days") } else { VStack(spacing: 0) { ForEach(viewModel.expiringSoon.prefix(4)) { chem in let days = viewModel.daysUntil(chem.expirationDate ?? "") ChemAlertRow( name: chem.chemicalName, subtitle: "\(chem.lab) · \(chem.storageLocation)", badge: days <= 0 ? "Expired \(-days)d ago" : "In \(days)d", badgeColor: days <= 0 ? .red : .orange ) if chem.id != viewModel.expiringSoon.prefix(4).last?.id { Divider().padding(.leading) } } } } } } } // MARK: - Recent activity private struct RecentActivitySection: View { let viewModel: DashboardViewModel var body: some View { DashboardCard(title: "Recent Activity", icon: "clock.arrow.circlepath") { if viewModel.recentActivity.isEmpty { EmptyStateRow(message: "No recent activity") } else { VStack(spacing: 0) { ForEach(viewModel.recentActivity) { chem in HStack(spacing: 12) { Image(systemName: "plus.circle.fill") .foregroundStyle(Color(.brandPrimary)) .font(.title3) VStack(alignment: .leading, spacing: 2) { Text("Added \(chem.chemicalName) to inventory") .font(.subheadline) .lineLimit(1) Text(viewModel.relativeTime(chem.createdAt)) .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding(.vertical, 8) if chem.id != viewModel.recentActivity.last?.id { Divider() } } } } } } } // MARK: - Shared sub-components private struct DashboardCard: View { let title: String let icon: String var iconColor: Color = Color(.brandPrimary) @ViewBuilder let content: () -> Content var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { Image(systemName: icon) .foregroundStyle(iconColor) .font(.subheadline.weight(.semibold)) Text(title) .font(.subheadline.weight(.semibold)) } content() } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color(UIColor.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } private struct ChemAlertRow: View { let name: String let subtitle: String let badge: String let badgeColor: Color var body: some View { HStack { VStack(alignment: .leading, spacing: 2) { Text(name) .font(.subheadline) .lineLimit(1) Text(subtitle) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() Text(badge) .font(.caption.weight(.semibold)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(badgeColor.opacity(0.12)) .foregroundStyle(badgeColor) .clipShape(Capsule()) } .padding(.vertical, 8) } } private struct EmptyStateRow: View { let message: String var body: some View { Text(message) .font(.subheadline) .foregroundStyle(.secondary) .padding(.vertical, 4) } } #Preview { DashboardView() }