Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/auth/data/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ kotlin {
implementation(libs.supabase.client)
implementation(libs.supabase.auth)
implementation(libs.supabase.postgrest)
implementation(libs.supabase.storage)
}
jvmMain.dependencies { implementation(libs.ktor.client.cio) }
androidMain.dependencies { implementation(libs.ktor.client.cio) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.storage.Storage

@SingleIn(AppScope::class)
@ContributesTo(AppScope::class)
Expand All @@ -33,6 +34,7 @@ interface SupabaseModule {
return createSupabaseClient(supabaseUrl = url, supabaseKey = key) {
install(Auth)
install(Postgrest)
install(Storage)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<resources>
<string name="create_recipe">Create Recipe</string>
<string name="edit_recipe">Edit Recipe</string>
<string name="edit_recipe_upload_failed">Failed to upload photo. Please try again.</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.plusmobileapps.chefmate.recipe.core.impl.edit
import chefmate.client.recipe.core.impl.generated.resources.Res
import chefmate.client.recipe.core.impl.generated.resources.create_recipe
import chefmate.client.recipe.core.impl.generated.resources.edit_recipe
import chefmate.client.recipe.core.impl.generated.resources.edit_recipe_upload_failed
import com.plusmobileapps.chefmate.BlocContext
import com.plusmobileapps.chefmate.Consumer
import com.plusmobileapps.chefmate.di.AppScope
Expand Down Expand Up @@ -50,6 +51,8 @@ class EditRecipeBlocImpl(
isLoading = it.isLoading,
isSaving = it.isSaving,
showDiscardChangesDialog = it.showDiscardChangesDialog,
uploadError =
it.uploadError?.let { ResourceString(Res.string.edit_recipe_upload_failed) },
)
}
override val title: StateFlow<String> = viewModel.title
Expand All @@ -67,6 +70,7 @@ class EditRecipeBlocImpl(
override val categories: StateFlow<Set<Category>> = viewModel.categories
override val availableUserCategories: StateFlow<List<Category>> =
viewModel.availableUserCategories
override val pendingPhotoBytes: StateFlow<ByteArray?> = viewModel.pendingPhotoBytes

init {
scope.launch {
Expand Down Expand Up @@ -172,6 +176,14 @@ class EditRecipeBlocImpl(
viewModel.save()
}

override fun onPhotoPicked(bytes: ByteArray, fileExtension: String) {
viewModel.setPendingPhoto(bytes = bytes, fileExtension = fileExtension)
}

override fun onUploadErrorDismissed() {
viewModel.dismissUploadError()
}

override fun onBackClicked() {
viewModel.tryToClose()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

package com.plusmobileapps.chefmate.recipe.core.impl.edit

import co.touchlab.kermit.Logger
import com.plusmobileapps.chefmate.ViewModel
import com.plusmobileapps.chefmate.di.Main
import com.plusmobileapps.chefmate.recipe.data.BuiltinCategory
import com.plusmobileapps.chefmate.recipe.data.Category
import com.plusmobileapps.chefmate.recipe.data.CategoryRepository
import com.plusmobileapps.chefmate.recipe.data.ExtractedRecipeData
import com.plusmobileapps.chefmate.recipe.data.Recipe
import com.plusmobileapps.chefmate.recipe.data.RecipePhotoStorage
import com.plusmobileapps.chefmate.recipe.data.RecipeRepository
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
Expand All @@ -35,6 +37,7 @@ class EditRecipeViewModel(
@Main mainContext: CoroutineContext,
private val repository: RecipeRepository,
private val categoryRepository: CategoryRepository,
private val photoStorage: RecipePhotoStorage,
) : ViewModel(mainContext) {
private val _output = Channel<Output>(Channel.BUFFERED)
val output: Flow<Output> = _output.receiveAsFlow()
Expand Down Expand Up @@ -91,6 +94,10 @@ class EditRecipeViewModel(
// UI stuck on a spinner).
private val _isCreatingCategory = MutableStateFlow(false)

private val _pendingPhotoBytes = MutableStateFlow<ByteArray?>(null)
val pendingPhotoBytes: StateFlow<ByteArray?> = _pendingPhotoBytes.asStateFlow()
private var pendingPhotoExtension: String? = null

init {
when {
recipeId != null -> {
Expand Down Expand Up @@ -250,11 +257,39 @@ class EditRecipeViewModel(
_state.update { it.copy(showDiscardChangesDialog = false) }
}

fun setPendingPhoto(bytes: ByteArray, fileExtension: String) {
_pendingPhotoBytes.value = bytes
pendingPhotoExtension = fileExtension
_state.update { it.copy(uploadError = null) }
}

fun dismissUploadError() {
_state.update { it.copy(uploadError = null) }
}

fun save() {
val originalRecipe = _state.value.recipe
val currentRecipe = currentRecipe()
_state.update { it.copy(isLoading = true) }
if (_state.value.isSaving) return
_state.update { it.copy(isSaving = true, uploadError = null) }
scope.launch {
val pendingBytes = _pendingPhotoBytes.value
val pendingExt = pendingPhotoExtension
if (pendingBytes != null && pendingExt != null) {
try {
val url =
photoStorage.uploadPhoto(bytes = pendingBytes, fileExtension = pendingExt)
_imageUrl.value = url
_pendingPhotoBytes.value = null
pendingPhotoExtension = null
} catch (t: Throwable) {
Logger.e(throwable = t, tag = "EditRecipeViewModel") {
"Failed to upload photo"
}
_state.update { it.copy(isSaving = false, uploadError = t) }
return@launch
}
}
val originalRecipe = _state.value.recipe
val currentRecipe = currentRecipe()
val savedRecipe =
if (originalRecipe != null) {
repository.updateRecipe(currentRecipe)
Expand Down Expand Up @@ -295,8 +330,9 @@ class EditRecipeViewModel(
private fun shouldShowDiscardChangesDialog(
originalRecipe: Recipe?,
currentRecipe: Recipe,
): Boolean =
when {
): Boolean {
if (_pendingPhotoBytes.value != null) return true
return when {
originalRecipe != null -> originalRecipe.isDirty()
else ->
currentRecipe.title.isNotBlank() ||
Expand All @@ -313,6 +349,7 @@ class EditRecipeViewModel(
currentRecipe.starRating != null ||
currentRecipe.categories.isNotEmpty()
}
}

private fun Recipe.isDirty(): Boolean =
title != _title.value ||
Expand Down Expand Up @@ -355,6 +392,7 @@ class EditRecipeViewModel(
val isSaving: Boolean = false,
val showDiscardChangesDialog: Boolean = false,
val recipe: Recipe? = null,
val uploadError: Throwable? = null,
)

sealed class Output {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.plusmobileapps.chefmate.recipe.data.Category
import com.plusmobileapps.chefmate.recipe.data.ExtractedRecipeData
import com.plusmobileapps.chefmate.recipe.data.Recipe
import com.plusmobileapps.chefmate.recipe.data.testing.FakeCategoryRepository
import com.plusmobileapps.chefmate.recipe.data.testing.FakeRecipePhotoStorage
import com.plusmobileapps.chefmate.recipe.data.testing.FakeRecipeRepository
import io.kotest.matchers.shouldBe
import kotlin.test.Test
Expand All @@ -24,6 +25,7 @@ class EditRecipeViewModelTest {
private val recipes = MutableStateFlow<List<Recipe>>(emptyList())
private val repository = FakeRecipeRepository(recipes)
private val categoryRepository = FakeCategoryRepository()
private val photoStorage = FakeRecipePhotoStorage()
private val mainContext = UnconfinedTestDispatcher()

private fun createViewModel(
Expand All @@ -36,6 +38,7 @@ class EditRecipeViewModelTest {
mainContext = mainContext,
repository = repository,
categoryRepository = categoryRepository,
photoStorage = photoStorage,
)

@Test
Expand Down Expand Up @@ -406,6 +409,64 @@ class EditRecipeViewModelTest {
recipes.value.first().categories shouldBe emptySet()
}

@Test
fun When_photo_picked_Then_bytes_are_held_and_no_upload_happens() {
val vm = createViewModel()

vm.setPendingPhoto(bytes = byteArrayOf(1, 2, 3), fileExtension = "jpg")

vm.pendingPhotoBytes.value?.toList() shouldBe listOf<Byte>(1, 2, 3)
vm.imageUrl.value shouldBe ""
photoStorage.uploads.size shouldBe 0
}

@Test
fun When_save_with_pending_photo_Then_photo_is_uploaded_before_recipe_save() = runTest {
photoStorage.nextResult = { "https://cdn.example.com/photo.jpg" }
val vm = createViewModel()
vm.updateTitle("With photo")
vm.setPendingPhoto(bytes = byteArrayOf(7, 7, 7), fileExtension = "png")

vm.save()
val output = vm.output.first()

output.shouldBeFinished()
photoStorage.uploads.size shouldBe 1
photoStorage.uploads.first().fileExtension shouldBe "png"
recipes.value.single().imageUrl shouldBe "https://cdn.example.com/photo.jpg"
vm.pendingPhotoBytes.value shouldBe null
vm.state.value.uploadError shouldBe null
}

@Test
fun When_save_upload_fails_Then_recipe_is_not_saved_and_error_is_surfaced() = runTest {
val failure = RuntimeException("network down")
photoStorage.nextResult = { throw failure }
val vm = createViewModel()
vm.updateTitle("Will not save")
vm.setPendingPhoto(bytes = byteArrayOf(0), fileExtension = "jpg")

vm.save()

recipes.value shouldBe emptyList()
vm.state.value.isSaving shouldBe false
vm.state.value.uploadError shouldBe failure
vm.pendingPhotoBytes.value?.toList() shouldBe listOf<Byte>(0)

vm.dismissUploadError()
vm.state.value.uploadError shouldBe null
}

@Test
fun When_only_photo_changed_Then_close_prompts_discard_dialog() {
val vm = createViewModel()
vm.setPendingPhoto(bytes = byteArrayOf(9), fileExtension = "jpg")

vm.tryToClose()

vm.state.value.showDiscardChangesDialog shouldBe true
}

private fun EditRecipeViewModel.Output.shouldBeFinished() {
check(this is EditRecipeViewModel.Output.Finished)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
<string name="edit_recipe_field_description_placeholder">Enter recipe description</string>
<string name="edit_recipe_field_image_url">Image URL</string>
<string name="edit_recipe_field_image_url_placeholder">Enter image URL</string>
<string name="edit_recipe_upload_photo">Upload photo</string>
<string name="edit_recipe_upload_photo_dismiss">OK</string>
<string name="edit_recipe_crop_title">Crop photo</string>
<string name="edit_recipe_crop_cancel">Cancel</string>
<string name="edit_recipe_crop_confirm">Crop</string>
<string name="edit_recipe_field_source_url">Source URL</string>
<string name="edit_recipe_field_source_url_placeholder">Enter source URL</string>
<string name="edit_recipe_field_servings">Servings</string>
Expand Down
Loading
Loading