405 lines
14 KiB
Kotlin
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
|