Manual Entry kinda works
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import SwiftUI
|
||||
import LabWiseKit
|
||||
|
||||
// MARK: - Tab shell
|
||||
|
||||
struct DashboardView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Chemicals", systemImage: "flask.fill") {
|
||||
ChemicalsListView()
|
||||
Tab("Dashboard", systemImage: "chart.bar.fill") {
|
||||
DashboardHomeView()
|
||||
}
|
||||
Tab("Scan", systemImage: "camera.fill") {
|
||||
ScanView()
|
||||
Tab("Inventory", systemImage: "flask.fill") {
|
||||
InventoryView()
|
||||
}
|
||||
Tab("Profile", systemImage: "person.fill") {
|
||||
ProfileView()
|
||||
@@ -17,6 +20,410 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<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)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DashboardView()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user