Files
LabWiseiOS/LabWise/DashboardView.swift

429 lines
14 KiB
Swift
Raw Normal View History

2026-03-20 02:05:53 -05:00
import SwiftUI
2026-03-20 02:30:15 -05:00
import LabWiseKit
// MARK: - Tab shell
2026-03-20 02:05:53 -05:00
struct DashboardView: View {
var body: some View {
TabView {
2026-03-20 02:30:15 -05:00
Tab("Dashboard", systemImage: "chart.bar.fill") {
DashboardHomeView()
2026-03-20 02:05:53 -05:00
}
2026-03-20 02:30:15 -05:00
Tab("Inventory", systemImage: "flask.fill") {
InventoryView()
2026-03-20 02:05:53 -05:00
}
Tab("Profile", systemImage: "person.fill") {
ProfileView()
}
}
.tint(Color(.brandPrimary))
2026-03-20 02:05:53 -05:00
}
}
2026-03-20 02:30:15 -05:00
// 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 {
errorMessage = "Failed to load dashboard"
2026-03-20 02:30:15 -05:00
}
}
// 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<Content: View>: 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)
}
}
2026-03-20 02:05:53 -05:00
#Preview {
DashboardView()
}