package com.keeply.pantry import android.app.Application import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.keeply.pantry.data.ApiClient import com.keeply.pantry.data.ApiException import com.keeply.pantry.data.AppDestination import com.keeply.pantry.data.asIndividualEntries import com.keeply.pantry.data.BarcodeLookup import com.keeply.pantry.data.DataState import com.keeply.pantry.data.Household import com.keeply.pantry.data.InventoryItem import com.keeply.pantry.data.InventoryItemRequest import com.keeply.pantry.data.Location import com.keeply.pantry.data.Notice import com.keeply.pantry.data.TokenStore import com.keeply.pantry.data.UpdateProfileRequest import com.keeply.pantry.data.UpdateUserRequest import com.keeply.pantry.data.User import kotlinx.coroutines.async import kotlinx.coroutines.launch sealed interface BarcodeResult { data class Found(val product: BarcodeLookup, val suggestedName: String = product.title) : BarcodeResult data object NotFound : BarcodeResult } class KeeplyViewModel(application: Application) : AndroidViewModel(application) { private val tokenStore = TokenStore(application) private val api = ApiClient(BuildConfig.API_BASE_URL, tokenStore) var initializing by mutableStateOf(true) private set var user by mutableStateOf(null) private set var destination by mutableStateOf(AppDestination.Pantry) private set var working by mutableStateOf(false) private set var authError by mutableStateOf(null) private set var actionError by mutableStateOf(null) private set var notice by mutableStateOf(null) private set var inventory by mutableStateOf(DataState>()) private set var locations by mutableStateOf(DataState>()) private set var households by mutableStateOf(DataState>()) private set var users by mutableStateOf(DataState>()) private set init { restoreSession() } private fun restoreSession() { if (tokenStore.accessToken.isNullOrBlank()) { initializing = false return } viewModelScope.launch { try { user = api.getProfile() loadPantry() } catch (_: Exception) { tokenStore.clear() } finally { initializing = false } } } fun login(email: String, password: String) { authError = null working = true viewModelScope.launch { try { val response = api.login(email.trim(), password) val signedInUser = response.user if (!response.success || response.accessToken.isNullOrBlank() || response.refreshToken.isNullOrBlank() || signedInUser == null ) { throw IllegalStateException(response.message ?: "Unable to sign in.") } tokenStore.set(response.accessToken, response.refreshToken) user = signedInUser destination = AppDestination.Pantry loadPantry() } catch (error: Exception) { authError = friendlyMessage(error, "Unable to sign in.") } finally { working = false } } } fun register( email: String, password: String, confirmPassword: String, firstName: String, lastName: String, ) { if (password != confirmPassword) { authError = "The passwords do not match." return } authError = null working = true viewModelScope.launch { try { val response = api.register( email.trim(), password, confirmPassword, firstName.trim(), lastName.trim(), ) val signedInUser = response.user if (!response.success || response.accessToken.isNullOrBlank() || response.refreshToken.isNullOrBlank() || signedInUser == null ) { throw IllegalStateException(response.message ?: "Unable to create your account.") } tokenStore.set(response.accessToken, response.refreshToken) user = signedInUser destination = AppDestination.Pantry loadPantry() } catch (error: Exception) { authError = friendlyMessage(error, "Unable to create your account.") } finally { working = false } } } fun clearAuthError() { authError = null } fun logout() { viewModelScope.launch { try { api.logout() } catch (_: Exception) { // A local sign-out must still succeed if the API is unavailable. } finally { tokenStore.clear() user = null inventory = DataState() locations = DataState() households = DataState() users = DataState() } } } fun navigate(to: AppDestination) { if (to == AppDestination.Users && user?.isSiteAdmin != true) return destination = to when (to) { AppDestination.Pantry -> loadPantry() AppDestination.Locations -> loadLocations() AppDestination.Households -> loadHouseholds() AppDestination.Users -> loadUsers() AppDestination.Profile -> Unit } } fun loadPantry(force: Boolean = false) { if (!force && inventory.data != null && locations.data != null) return inventory = inventory.copy(loading = true, error = null) locations = locations.copy(loading = true, error = null) viewModelScope.launch { try { val itemRequest = async { api.getItems() } val locationRequest = async { api.getLocations() } inventory = DataState(data = itemRequest.await()) locations = DataState(data = locationRequest.await()) } catch (error: Exception) { val message = friendlyMessage(error, "Your pantry could not be loaded.") inventory = inventory.copy(loading = false, error = message) locations = locations.copy(loading = false, error = message) } } } fun loadLocations(force: Boolean = false) { if (!force && locations.data != null) return locations = locations.copy(loading = true, error = null) viewModelScope.launch { locations = try { DataState(data = api.getLocations()) } catch (error: Exception) { DataState(error = friendlyMessage(error, "Locations could not be loaded.")) } } } fun loadHouseholds(force: Boolean = false) { if (!force && households.data != null) return households = households.copy(loading = true, error = null) viewModelScope.launch { households = try { DataState(data = api.getHouseholds()) } catch (error: Exception) { DataState(error = friendlyMessage(error, "Households could not be loaded.")) } } } fun loadUsers(force: Boolean = false) { if (user?.isSiteAdmin != true || (!force && users.data != null)) return users = users.copy(loading = true, error = null) viewModelScope.launch { users = try { DataState(data = api.getUsers()) } catch (error: Exception) { DataState(error = friendlyMessage(error, "Users could not be loaded.")) } } } fun saveItem(item: InventoryItem?, request: InventoryItemRequest, onSuccess: () -> Unit) = action( successMessage = if (item == null) "Item added to your pantry." else "Pantry item updated.", block = { if (item == null) api.createItem(request) else api.updateItem(item.id, request) }, onSuccess = { loadPantry(force = true) onSuccess() }, ) fun saveScannedItems(request: InventoryItemRequest, quantity: Int, onSuccess: () -> Unit) = action( successMessage = if (quantity == 1) "Item added to your pantry." else "$quantity individual items added to your pantry.", block = { request.asIndividualEntries(quantity).forEach { api.createItem(it) } }, onSuccess = { loadPantry(force = true) onSuccess() }, ) fun deleteItem(item: InventoryItem, onSuccess: () -> Unit = {}) = action( successMessage = "${item.title} was removed.", block = { api.deleteItem(item.id) }, onSuccess = { inventory = inventory.copy(data = inventory.data.orEmpty().filterNot { it.id == item.id }) onSuccess() }, ) fun lookupBarcode(barcode: String, onResult: (Result) -> Unit) { working = true actionError = null viewModelScope.launch { val result = runCatching { val normalized = barcode.trim() val existing = api.searchItems(normalized).firstOrNull { it.barcode?.trim()?.equals(normalized, ignoreCase = true) == true } if (existing != null) { BarcodeResult.Found( BarcodeLookup( barcode = existing.barcode ?: normalized, title = existing.name, size = existing.itemLookupSize, imageUrl = existing.itemImageUrl, ), existing.name, ) } else { try { BarcodeResult.Found(api.lookupBarcode(normalized)) } catch (error: ApiException) { if (error.status == 404) BarcodeResult.NotFound else throw error } } } working = false result.exceptionOrNull()?.let { actionError = friendlyMessage(it, "We could not look up this barcode.") } onResult(result) } } fun saveLocation(location: Location?, name: String, description: String?, onSuccess: () -> Unit) = action( successMessage = if (location == null) "Location added." else "Location updated.", block = { if (location == null) api.createLocation(name, description) else api.updateLocation(location.id, name, description) }, onSuccess = { saved -> val current = locations.data.orEmpty() locations = DataState( data = if (current.any { it.id == saved.id }) current.map { if (it.id == saved.id) saved else it } else current + saved, ) onSuccess() }, ) fun deleteLocation(location: Location, onSuccess: () -> Unit) = action( successMessage = "${location.name} was removed.", block = { api.deleteLocation(location.id) }, onSuccess = { locations = locations.copy(data = locations.data.orEmpty().filterNot { it.id == location.id }) onSuccess() }, ) fun saveHousehold(household: Household?, name: String, description: String?, onSuccess: () -> Unit) = action( successMessage = if (household == null) "Household created." else "Household updated.", block = { if (household == null) api.createHousehold(name, description) else api.updateHousehold(household.id, name, description) }, onSuccess = { saved -> upsertHousehold(saved) onSuccess() }, ) fun inviteMember(household: Household, email: String, onSuccess: () -> Unit) = action( successMessage = "Member added to ${household.name}.", block = { api.inviteHouseholdMember(household.id, email) }, onSuccess = { saved -> upsertHousehold(saved) onSuccess() }, ) fun leaveHousehold(household: Household, onSuccess: () -> Unit) = action( successMessage = "You left ${household.name}.", block = { api.leaveHousehold(household.id) }, onSuccess = { households = households.copy(data = households.data.orEmpty().filterNot { it.id == household.id }) onSuccess() }, ) fun updateProfile(request: UpdateProfileRequest, successMessage: String, onSuccess: () -> Unit) = action( successMessage = successMessage, block = { api.updateProfile(request) }, onSuccess = { updated -> user = updated onSuccess() }, ) fun updateUser(person: User, request: UpdateUserRequest, onSuccess: () -> Unit) = action( successMessage = "${person.displayName} was updated.", block = { api.updateUser(person.id, request) }, onSuccess = { updated -> users = users.copy(data = users.data.orEmpty().map { if (it.id == updated.id) updated else it }) if (updated.id == user?.id) user = updated onSuccess() }, ) fun clearActionError() { actionError = null } fun consumeNotice() { notice = null } private fun upsertHousehold(saved: Household) { val current = households.data.orEmpty() households = DataState( data = if (current.any { it.id == saved.id }) current.map { if (it.id == saved.id) saved else it } else current + saved, ) } private fun action( successMessage: String, block: suspend () -> T, onSuccess: (T) -> Unit, ) { working = true actionError = null viewModelScope.launch { try { val result = block() onSuccess(result) notice = Notice(message = successMessage) } catch (error: Exception) { actionError = friendlyMessage(error, "The change could not be saved.") } finally { working = false } } } } private fun friendlyMessage(error: Throwable, fallback: String): String = error.message?.takeIf { it.isNotBlank() } ?: fallback