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 ?
2026-05-01 13:54:05 -05:00
var missingInfoCount : Int {
chemicals . filter ( \ . isMissingKeyInfo ) . count
}
2026-03-20 02:30:15 -05:00
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 {
2026-05-01 13:54:05 -05:00
if viewModel . missingInfoCount > 0 && ! isSelectMode {
Section {
MissingInfoBanner ( count : viewModel . missingInfoCount )
}
. listRowBackground ( Color . orange . opacity ( 0.08 ) )
}
2026-03-20 02:30:15 -05:00
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 ) // 📍 F o r c e i t t o f i l l t h e r o w
. contentShape ( Rectangle ( ) ) // 📍 M a k e t h e t r a n s p a r e n t s p a c e t a p p a b l e
}
. 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
}
}
2026-05-01 13:54:05 -05:00
// MARK: - M i s s i n g - i n f o b a n n e r
// / S h o w n a b o v e t h e i n v e n t o r y l i s t w h e n o n e o r m o r e r o w s a r e m i s s i n g r e q u i r e d
// / f i e l d s ( t y p i c a l l y t h e r e s u l t o f a w e b s p r e a d s h e e t i m p o r t ) . M i r r o r s t h e
// / a m b e r b a n n e r o n t h e w e b i n v e n t o r y p a g e .
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 )
}
}