2026-03-20 02:30:15 -05:00
|
|
|
import SwiftUI
|
|
|
|
|
import LabWiseKit
|
|
|
|
|
|
|
|
|
|
@Observable
|
|
|
|
|
final class InventoryViewModel {
|
|
|
|
|
var chemicals: [Chemical] = []
|
|
|
|
|
var isLoading = false
|
|
|
|
|
var errorMessage: String?
|
|
|
|
|
|
|
|
|
|
private let client = ChemicalsClient()
|
|
|
|
|
|
|
|
|
|
func loadChemicals() async {
|
|
|
|
|
isLoading = true
|
|
|
|
|
errorMessage = nil
|
|
|
|
|
defer { isLoading = false }
|
|
|
|
|
do {
|
|
|
|
|
chemicals = try await client.list()
|
|
|
|
|
} catch {
|
2026-03-20 02:40:51 -05:00
|
|
|
errorMessage = "Failed to load chemicals"
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func delete(chemical: Chemical) async {
|
|
|
|
|
do {
|
|
|
|
|
try await client.delete(id: chemical.id)
|
|
|
|
|
chemicals.removeAll { $0.id == chemical.id }
|
|
|
|
|
} catch {
|
2026-03-20 02:40:51 -05:00
|
|
|
errorMessage = "Failed to delete"
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 00:02:44 -05:00
|
|
|
|
|
|
|
|
func deleteSelected(ids: Set<String>) 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"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct InventoryView: View {
|
|
|
|
|
@State private var viewModel = InventoryViewModel()
|
|
|
|
|
@State private var addMode: AddMode?
|
2026-04-05 00:02:44 -05:00
|
|
|
@State private var isSelectMode = false
|
|
|
|
|
@State private var selectedIDs: Set<String> = []
|
2026-04-05 00:15:25 -05:00
|
|
|
@State private var showSpreadsheet = false
|
2026-03-20 02:30:15 -05:00
|
|
|
|
|
|
|
|
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 {
|
2026-04-10 22:20:12 -05:00
|
|
|
ScrollView {
|
|
|
|
|
ContentUnavailableView(
|
|
|
|
|
"No Chemicals",
|
|
|
|
|
systemImage: "flask",
|
|
|
|
|
description: Text("Add your first chemical using the + button.")
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
.refreshable {
|
|
|
|
|
await viewModel.loadChemicals()
|
|
|
|
|
}
|
2026-03-20 02:30:15 -05:00
|
|
|
} else {
|
|
|
|
|
List {
|
|
|
|
|
ForEach(viewModel.chemicals) { chemical in
|
2026-04-05 00:02:44 -05:00
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 00:02:44 -05:00
|
|
|
.onDelete(perform: isSelectMode ? nil : { indexSet in
|
2026-03-20 02:30:15 -05:00
|
|
|
for index in indexSet {
|
|
|
|
|
let chemical = viewModel.chemicals[index]
|
|
|
|
|
Task { await viewModel.delete(chemical: chemical) }
|
|
|
|
|
}
|
2026-04-05 00:02:44 -05:00
|
|
|
})
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
.refreshable {
|
|
|
|
|
await viewModel.loadChemicals()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 00:02:44 -05:00
|
|
|
.navigationTitle(isSelectMode ? "\(selectedIDs.count) Selected" : "Inventory")
|
2026-03-20 02:30:15 -05:00
|
|
|
.toolbar {
|
2026-04-05 00:02:44 -05:00
|
|
|
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 = []
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-20 02:30:15 -05:00
|
|
|
} label: {
|
2026-04-05 00:02:44 -05:00
|
|
|
Text("Delete (\(selectedIDs.count))")
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
2026-04-05 00:02:44 -05:00
|
|
|
.disabled(selectedIDs.isEmpty)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-04-05 00:15:25 -05:00
|
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
|
|
|
Button {
|
|
|
|
|
showSpreadsheet = true
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: "tablecells")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 00:02:44 -05:00
|
|
|
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")
|
|
|
|
|
}
|
2026-03-20 02:30:15 -05:00
|
|
|
} label: {
|
2026-04-05 00:02:44 -05:00
|
|
|
Image(systemName: "plus")
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.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()
|
2026-03-22 01:35:53 -05:00
|
|
|
.onDisappear {
|
|
|
|
|
Task { await viewModel.loadChemicals() }
|
|
|
|
|
}
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.task {
|
|
|
|
|
await viewModel.loadChemicals()
|
|
|
|
|
}
|
2026-04-05 00:15:25 -05:00
|
|
|
.fullScreenCover(isPresented: $showSpreadsheet) {
|
|
|
|
|
SpreadsheetView(chemicals: viewModel.chemicals) {
|
|
|
|
|
showSpreadsheet = false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-20 02:30:15 -05:00
|
|
|
}
|
|
|
|
|
}
|