Initial commit
This commit is contained in:
404
app/src/main/java/com/keeply/pantry/KeeplyViewModel.kt
Normal file
404
app/src/main/java/com/keeply/pantry/KeeplyViewModel.kt
Normal file
@@ -0,0 +1,404 @@
|
||||
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
|
||||
Reference in New Issue
Block a user