import SwiftUI import LabWiseKit @Observable final class InventoryViewModel { var chemicals: [Chemical] = [] var isLoading = false var errorMessage: String? var missingInfoCount: Int { chemicals.filter(\.isMissingKeyInfo).count } private let client = ChemicalsClient() func loadChemicals() async { isLoading = true errorMessage = nil defer { isLoading = false } do { chemicals = try await client.list() } catch { errorMessage = "Failed to load chemicals" } } func delete(chemical: Chemical) async { do { try await client.delete(id: chemical.id) chemicals.removeAll { $0.id == chemical.id } } catch { errorMessage = "Failed to delete" } } func deleteSelected(ids: Set) async { for id in ids { guard let chemical = chemicals.first(where: { $0.id == id }) else { continue } do { try await client.delete(id: chemical.id) chemicals.removeAll { $0.id == id } } catch { errorMessage = "Failed to delete some items" } } } } struct InventoryView: View { @State private var viewModel = InventoryViewModel() @State private var addMode: AddMode? @State private var isSelectMode = false @State private var selectedIDs: Set = [] @State private var showSpreadsheet = false enum AddMode: String, Identifiable { case manual, scan var id: String { rawValue } } var body: some View { NavigationStack { Group { if viewModel.isLoading && viewModel.chemicals.isEmpty { ProgressView("Loading...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.chemicals.isEmpty { ScrollView { ContentUnavailableView( "No Chemicals", systemImage: "flask", description: Text("Add your first chemical using the + button.") ) } .refreshable { await viewModel.loadChemicals() } } else { List { if viewModel.missingInfoCount > 0 && !isSelectMode { Section { MissingInfoBanner(count: viewModel.missingInfoCount) } .listRowBackground(Color.orange.opacity(0.08)) } ForEach(viewModel.chemicals) { chemical in if isSelectMode { Button { if selectedIDs.contains(chemical.id) { selectedIDs.remove(chemical.id) } else { selectedIDs.insert(chemical.id) } } label: { HStack(spacing: 12) { Image(systemName: selectedIDs.contains(chemical.id) ? "checkmark.circle.fill" : "circle") .foregroundStyle(selectedIDs.contains(chemical.id) ? .blue : .secondary) .font(.title3) .animation(.easeInOut(duration: 0.15), value: selectedIDs.contains(chemical.id)) ChemicalRowView(chemical: chemical) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) } else { NavigationLink(destination: ChemicalDetailView(chemical: chemical)) { ChemicalRowView(chemical: chemical) .frame(maxWidth: .infinity, alignment: .leading) // 📍 Force it to fill the row .contentShape(Rectangle()) // 📍 Make the transparent space tappable } .simultaneousGesture( LongPressGesture(minimumDuration: 0.5).onEnded { _ in withAnimation { isSelectMode = true selectedIDs = [chemical.id] } } ) } } .onDelete(perform: isSelectMode ? nil : { indexSet in for index in indexSet { let chemical = viewModel.chemicals[index] Task { await viewModel.delete(chemical: chemical) } } }) } .refreshable { await viewModel.loadChemicals() } } } .navigationTitle(isSelectMode ? "\(selectedIDs.count) Selected" : "Inventory") .toolbar { if isSelectMode { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { withAnimation { isSelectMode = false selectedIDs = [] } } } ToolbarItem(placement: .topBarTrailing) { Button(role: .destructive) { Task { await viewModel.deleteSelected(ids: selectedIDs) withAnimation { isSelectMode = false selectedIDs = [] } } } label: { Text("Delete (\(selectedIDs.count))") } .disabled(selectedIDs.isEmpty) } } else { ToolbarItem(placement: .topBarLeading) { Button { showSpreadsheet = true } label: { Image(systemName: "tablecells") } } ToolbarItem(placement: .topBarTrailing) { Menu { Button { addMode = .scan } label: { Label("Scan Label", systemImage: "camera.fill") } Button { addMode = .manual } label: { Label("Manual Entry", systemImage: "square.and.pencil") } } label: { Image(systemName: "plus") } } } } .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { Button("OK") { viewModel.errorMessage = nil } } message: { Text(viewModel.errorMessage ?? "") } } .sheet(item: $addMode) { mode in switch mode { case .manual: AddChemicalView { saved in addMode = nil if saved { Task { await viewModel.loadChemicals() } } } case .scan: ScanView() .onDisappear { Task { await viewModel.loadChemicals() } } } } .task { await viewModel.loadChemicals() } .fullScreenCover(isPresented: $showSpreadsheet) { SpreadsheetView(chemicals: viewModel.chemicals) { showSpreadsheet = false } } } } // MARK: - Missing-info banner /// Shown above the inventory list when one or more rows are missing required /// fields (typically the result of a web spreadsheet import). Mirrors the /// amber banner on the web inventory page. private struct MissingInfoBanner: View { let count: Int var body: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) VStack(alignment: .leading, spacing: 2) { Text("Missing Key Information") .font(.subheadline.weight(.semibold)) Text("\(count) \(count == 1 ? "item is" : "items are") missing required fields. Tap an item flagged with “Missing info” to see exactly which fields and edit them.") .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 4) } }