Files
new-pantry-management-andro…/app/src/main/java/com/keeply/pantry/KeeplyViewModel.kt
2026-07-22 14:08:23 +01:00

405 lines
14 KiB
Kotlin

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<User?>(null)
private set
var destination by mutableStateOf(AppDestination.Pantry)
private set
var working by mutableStateOf(false)
private set
var authError by mutableStateOf<String?>(null)
private set
var actionError by mutableStateOf<String?>(null)
private set
var notice by mutableStateOf<Notice?>(null)
private set
var inventory by mutableStateOf(DataState<List<InventoryItem>>())
private set
var locations by mutableStateOf(DataState<List<Location>>())
private set
var households by mutableStateOf(DataState<List<Household>>())
private set
var users by mutableStateOf(DataState<List<User>>())
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<BarcodeResult>) -> 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 <T> 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