From 3b762f45a08eae2b69ca350779555859dc5bea79 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 7 May 2026 23:29:43 -0700 Subject: [PATCH 1/9] feat(recipe): upload recipe photos via Supabase Storage Adds an image picker on the edit recipe screen and uploads the selected photo to a "recipe-photos" Supabase Storage bucket, then sets the public URL as the recipe's image. Includes: - Multiplatform expect/actual image picker (Android PickVisualMedia, iOS PHPickerViewController, JVM JFileChooser). - RecipePhotoStorage interface with a Supabase-backed implementation scoped per signed-in user. - ViewModel state + tests for upload progress and failures. Closes #129 Co-Authored-By: Claude Opus 4.7 --- client/auth/data/impl/build.gradle.kts | 1 + .../chefmate/auth/data/impl/SupabaseModule.kt | 2 + .../composeResources/values/strings.xml | 1 + .../core/impl/edit/EditRecipeBlocImpl.kt | 12 ++ .../core/impl/edit/EditRecipeViewModel.kt | 24 ++++ .../core/impl/edit/EditRecipeViewModelTest.kt | 33 ++++++ .../composeResources/values/strings.xml | 2 + .../recipe/core/edit/EditRecipeBloc.kt | 6 + .../recipe/core/edit/EditRecipeScreen.kt | 77 +++++++++++++ client/recipe/data/impl/build.gradle.kts | 1 + .../impl/remote/SupabaseRecipePhotoStorage.kt | 39 +++++++ .../recipe/data/RecipePhotoStorage.kt | 5 + .../data/testing/FakeRecipePhotoStorage.kt | 25 +++++ client/util/public/build.gradle.kts | 5 +- .../chefmate/util/ImagePickerUtil.android.kt | 47 ++++++++ .../chefmate/util/ImagePickerUtil.kt | 21 ++++ .../chefmate/util/ImagePickerUtil.ios.kt | 106 ++++++++++++++++++ .../chefmate/util/ImagePickerUtil.jvm.kt | 49 ++++++++ gradle/libs.versions.toml | 1 + 19 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt create mode 100644 client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt create mode 100644 client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt create mode 100644 client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.android.kt create mode 100644 client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.kt create mode 100644 client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.ios.kt create mode 100644 client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.jvm.kt diff --git a/client/auth/data/impl/build.gradle.kts b/client/auth/data/impl/build.gradle.kts index 1959f7d3..1d933d7d 100644 --- a/client/auth/data/impl/build.gradle.kts +++ b/client/auth/data/impl/build.gradle.kts @@ -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) } diff --git a/client/auth/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/data/impl/SupabaseModule.kt b/client/auth/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/data/impl/SupabaseModule.kt index 49eee842..d30a7676 100644 --- a/client/auth/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/data/impl/SupabaseModule.kt +++ b/client/auth/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/data/impl/SupabaseModule.kt @@ -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) @@ -33,6 +34,7 @@ interface SupabaseModule { return createSupabaseClient(supabaseUrl = url, supabaseKey = key) { install(Auth) install(Postgrest) + install(Storage) } } } diff --git a/client/recipe/core/impl/src/commonMain/composeResources/values/strings.xml b/client/recipe/core/impl/src/commonMain/composeResources/values/strings.xml index e85c3f54..f9b59e27 100644 --- a/client/recipe/core/impl/src/commonMain/composeResources/values/strings.xml +++ b/client/recipe/core/impl/src/commonMain/composeResources/values/strings.xml @@ -1,4 +1,5 @@ Create Recipe Edit Recipe + Failed to upload photo. Please try again. \ No newline at end of file diff --git a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt index 4bb6ffe7..c961f201 100644 --- a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt +++ b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt @@ -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 @@ -49,7 +50,10 @@ class EditRecipeBlocImpl( }, isLoading = it.isLoading, isSaving = it.isSaving, + isUploadingPhoto = it.isUploadingPhoto, showDiscardChangesDialog = it.showDiscardChangesDialog, + uploadError = + it.uploadError?.let { ResourceString(Res.string.edit_recipe_upload_failed) }, ) } override val title: StateFlow = viewModel.title @@ -172,6 +176,14 @@ class EditRecipeBlocImpl( viewModel.save() } + override fun onPhotoPicked(bytes: ByteArray, fileExtension: String) { + viewModel.uploadPhoto(bytes = bytes, fileExtension = fileExtension) + } + + override fun onUploadErrorDismissed() { + viewModel.dismissUploadError() + } + override fun onBackClicked() { viewModel.tryToClose() } diff --git a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt index f7f2aa64..ec5a7f82 100644 --- a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt +++ b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt @@ -2,6 +2,7 @@ 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 @@ -9,6 +10,7 @@ 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 @@ -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(Channel.BUFFERED) val output: Flow = _output.receiveAsFlow() @@ -250,6 +253,25 @@ class EditRecipeViewModel( _state.update { it.copy(showDiscardChangesDialog = false) } } + fun uploadPhoto(bytes: ByteArray, fileExtension: String) { + if (_state.value.isUploadingPhoto) return + _state.update { it.copy(isUploadingPhoto = true, uploadError = null) } + scope.launch { + try { + val url = photoStorage.uploadPhoto(bytes = bytes, fileExtension = fileExtension) + _imageUrl.value = url + _state.update { it.copy(isUploadingPhoto = false) } + } catch (t: Throwable) { + Logger.e(throwable = t, tag = "EditRecipeViewModel") { "Failed to upload photo" } + _state.update { it.copy(isUploadingPhoto = false, uploadError = t) } + } + } + } + + fun dismissUploadError() { + _state.update { it.copy(uploadError = null) } + } + fun save() { val originalRecipe = _state.value.recipe val currentRecipe = currentRecipe() @@ -353,8 +375,10 @@ class EditRecipeViewModel( data class State( val isLoading: Boolean = false, val isSaving: Boolean = false, + val isUploadingPhoto: Boolean = false, val showDiscardChangesDialog: Boolean = false, val recipe: Recipe? = null, + val uploadError: Throwable? = null, ) sealed class Output { diff --git a/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt b/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt index 99847449..8cf550a4 100644 --- a/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt +++ b/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt @@ -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 @@ -24,6 +25,7 @@ class EditRecipeViewModelTest { private val recipes = MutableStateFlow>(emptyList()) private val repository = FakeRecipeRepository(recipes) private val categoryRepository = FakeCategoryRepository() + private val photoStorage = FakeRecipePhotoStorage() private val mainContext = UnconfinedTestDispatcher() private fun createViewModel( @@ -36,6 +38,7 @@ class EditRecipeViewModelTest { mainContext = mainContext, repository = repository, categoryRepository = categoryRepository, + photoStorage = photoStorage, ) @Test @@ -406,6 +409,36 @@ class EditRecipeViewModelTest { recipes.value.first().categories shouldBe emptySet() } + @Test + fun When_photo_uploaded_Then_image_url_is_updated() = runTest { + photoStorage.nextResult = { "https://cdn.example.com/photo.jpg" } + val vm = createViewModel() + + vm.uploadPhoto(bytes = byteArrayOf(1, 2, 3), fileExtension = "jpg") + + vm.imageUrl.value shouldBe "https://cdn.example.com/photo.jpg" + vm.state.value.isUploadingPhoto shouldBe false + vm.state.value.uploadError shouldBe null + photoStorage.uploads.size shouldBe 1 + photoStorage.uploads.first().fileExtension shouldBe "jpg" + } + + @Test + fun When_photo_upload_fails_Then_error_is_surfaced_and_url_is_unchanged() = runTest { + val failure = RuntimeException("network down") + photoStorage.nextResult = { throw failure } + val vm = createViewModel() + + vm.uploadPhoto(bytes = byteArrayOf(0), fileExtension = "png") + + vm.imageUrl.value shouldBe "" + vm.state.value.isUploadingPhoto shouldBe false + vm.state.value.uploadError shouldBe failure + + vm.dismissUploadError() + vm.state.value.uploadError shouldBe null + } + private fun EditRecipeViewModel.Output.shouldBeFinished() { check(this is EditRecipeViewModel.Output.Finished) } diff --git a/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml b/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml index 8b83a112..5c2da438 100644 --- a/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml +++ b/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml @@ -69,6 +69,8 @@ Enter recipe description Image URL Enter image URL + Upload photo + OK Source URL Enter source URL Servings diff --git a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt index 11f40a43..ae7af0fe 100644 --- a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt @@ -102,11 +102,17 @@ interface EditRecipeBloc : BackClickBloc { fun onSaveClicked() + fun onPhotoPicked(bytes: ByteArray, fileExtension: String) + + fun onUploadErrorDismissed() + data class Model( val title: TextData, val isLoading: Boolean, val isSaving: Boolean, + val isUploadingPhoto: Boolean, val showDiscardChangesDialog: Boolean, + val uploadError: TextData? = null, ) sealed class Output { diff --git a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt index 64271267..1ca0598f 100644 --- a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarOutline @@ -24,6 +26,7 @@ import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -78,12 +81,16 @@ import chefmate.client.recipe.core.public.generated.resources.edit_recipe_field_ import chefmate.client.recipe.core.public.generated.resources.edit_recipe_field_total_time import chefmate.client.recipe.core.public.generated.resources.edit_recipe_field_total_time_placeholder import chefmate.client.recipe.core.public.generated.resources.edit_recipe_save +import chefmate.client.recipe.core.public.generated.resources.edit_recipe_upload_photo +import chefmate.client.recipe.core.public.generated.resources.edit_recipe_upload_photo_dismiss import com.plusmobileapps.chefmate.recipe.data.BuiltinCategory import com.plusmobileapps.chefmate.text.FixedString import com.plusmobileapps.chefmate.ui.components.PlusHeaderContainer import com.plusmobileapps.chefmate.ui.components.PlusHeaderData import com.plusmobileapps.chefmate.ui.components.PlusLoadingIndicator +import com.plusmobileapps.chefmate.ui.components.RecipeImage import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme +import com.plusmobileapps.chefmate.util.rememberImagePickerLauncher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.jetbrains.compose.resources.StringResource @@ -101,6 +108,10 @@ fun EditRecipeScreen(bloc: EditRecipeBloc, modifier: Modifier = Modifier) { ) } + state.uploadError?.let { error -> + UploadErrorDialog(message = error.localized(), onDismiss = bloc::onUploadErrorDismissed) + } + PlusHeaderContainer( modifier = modifier.fillMaxSize(), data = PlusHeaderData.Child(title = state.title, onBackClick = bloc::onBackClicked), @@ -152,6 +163,7 @@ private fun EditRecipeContent(bloc: EditRecipeBloc, modifier: Modifier = Modifie RecipeDescriptionField(bloc = bloc) RecipeCategoryField(bloc = bloc) RecipeStarRatingField(bloc = bloc) + RecipePhotoUploader(bloc = bloc) RecipeImageUrlField(bloc = bloc) RecipeSourceUrlField(bloc = bloc) RecipeServingsField(bloc = bloc) @@ -326,6 +338,66 @@ private fun RecipeStarRatingField(bloc: EditRecipeBloc, modifier: Modifier = Mod } } +@Composable +private fun RecipePhotoUploader(bloc: EditRecipeBloc, modifier: Modifier = Modifier) { + val state by bloc.state.collectAsState() + val imageUrl by bloc.imageUrl.collectAsState() + val pickPhoto = rememberImagePickerLauncher { picked -> + if (picked != null) { + bloc.onPhotoPicked(picked.bytes, picked.fileExtension) + } + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + ) { + if (imageUrl.isNotBlank()) { + RecipeImage( + imageUrl = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + ) + } + OutlinedButton( + onClick = pickPhoto, + enabled = !state.isUploadingPhoto, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isUploadingPhoto) { + PlusLoadingIndicator( + modifier = Modifier.padding(end = ChefMateTheme.dimens.paddingSmall) + ) + } else { + Icon( + imageVector = Icons.Filled.AddPhotoAlternate, + contentDescription = null, + modifier = Modifier.padding(end = ChefMateTheme.dimens.paddingSmall), + ) + } + Text(stringResource(Res.string.edit_recipe_upload_photo)) + } + } +} + +@Composable +private fun UploadErrorDialog( + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.edit_recipe_upload_photo_dismiss)) + } + }, + modifier = modifier, + ) +} + @Composable private fun RecipeImageUrlField(bloc: EditRecipeBloc, modifier: Modifier = Modifier) { val imageUrl by bloc.imageUrl.collectAsState() @@ -488,6 +560,7 @@ private val previewBloc = title = FixedString("Edit Recipe"), isLoading = false, isSaving = false, + isUploadingPhoto = false, showDiscardChangesDialog = false, ) ) @@ -587,6 +660,10 @@ Salt for pasta water""" override fun onSaveClicked() {} + override fun onPhotoPicked(bytes: ByteArray, fileExtension: String) {} + + override fun onUploadErrorDismissed() {} + override fun onBackClicked() {} } diff --git a/client/recipe/data/impl/build.gradle.kts b/client/recipe/data/impl/build.gradle.kts index 32ab499b..85b7562e 100644 --- a/client/recipe/data/impl/build.gradle.kts +++ b/client/recipe/data/impl/build.gradle.kts @@ -13,6 +13,7 @@ kotlin { implementation(projects.client.auth.data.public) implementation(libs.supabase.client) implementation(libs.supabase.postgrest) + implementation(libs.supabase.storage) implementation(libs.kotlinx.serialization.json) } commonTest.dependencies { diff --git a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt new file mode 100644 index 00000000..0bc90e91 --- /dev/null +++ b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt @@ -0,0 +1,39 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package com.plusmobileapps.chefmate.recipe.data.impl.remote + +import com.plusmobileapps.chefmate.auth.data.AuthState +import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository +import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.recipe.data.RecipePhotoStorage +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.storage.storage +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Inject +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class SupabaseRecipePhotoStorage( + private val supabaseClient: SupabaseClient, + private val authRepository: AuthenticationRepository, +) : RecipePhotoStorage { + + override suspend fun uploadPhoto(bytes: ByteArray, fileExtension: String): String { + val ownerId = + (authRepository.state.value as? AuthState.Authenticated)?.user?.userId + ?: error("Cannot upload recipe photo while signed out") + val bucket = supabaseClient.storage.from(BUCKET_NAME) + val sanitizedExtension = fileExtension.trimStart('.').lowercase().ifBlank { "jpg" } + val path = "$ownerId/${Uuid.random()}.$sanitizedExtension" + bucket.upload(path = path, data = bytes) { upsert = false } + return bucket.publicUrl(path) + } + + private companion object { + const val BUCKET_NAME = "recipe-photos" + } +} diff --git a/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt b/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt new file mode 100644 index 00000000..59670ad6 --- /dev/null +++ b/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt @@ -0,0 +1,5 @@ +package com.plusmobileapps.chefmate.recipe.data + +interface RecipePhotoStorage { + suspend fun uploadPhoto(bytes: ByteArray, fileExtension: String): String +} diff --git a/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt b/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt new file mode 100644 index 00000000..5badab23 --- /dev/null +++ b/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt @@ -0,0 +1,25 @@ +package com.plusmobileapps.chefmate.recipe.data.testing + +import com.plusmobileapps.chefmate.recipe.data.RecipePhotoStorage + +class FakeRecipePhotoStorage(var nextResult: () -> String = { "https://example.com/photo.jpg" }) : + RecipePhotoStorage { + val uploads = mutableListOf() + + override suspend fun uploadPhoto(bytes: ByteArray, fileExtension: String): String { + uploads.add(Upload(bytes = bytes, fileExtension = fileExtension)) + return nextResult() + } + + data class Upload(val bytes: ByteArray, val fileExtension: String) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Upload) return false + if (fileExtension != other.fileExtension) return false + if (!bytes.contentEquals(other.bytes)) return false + return true + } + + override fun hashCode(): Int = 31 * bytes.contentHashCode() + fileExtension.hashCode() + } +} diff --git a/client/util/public/build.gradle.kts b/client/util/public/build.gradle.kts index 0a73e027..c1b762e4 100644 --- a/client/util/public/build.gradle.kts +++ b/client/util/public/build.gradle.kts @@ -14,7 +14,10 @@ kotlin { implementation(compose.ui) } - androidMain.dependencies { implementation(libs.androidx.annotation) } + androidMain.dependencies { + implementation(libs.androidx.annotation) + implementation(libs.androidx.activity.compose) + } } } diff --git a/client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.android.kt b/client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.android.kt new file mode 100644 index 00000000..50927123 --- /dev/null +++ b/client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.android.kt @@ -0,0 +1,47 @@ +@file:Suppress("ktlint:standard:filename") + +package com.plusmobileapps.chefmate.util + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberImagePickerLauncher(onResult: (PickedImage?) -> Unit): () -> Unit { + val context = LocalContext.current + val currentOnResult = rememberUpdatedState(onResult) + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) { + currentOnResult.value(null) + return@rememberLauncherForActivityResult + } + val resolver = context.contentResolver + val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes == null) { + currentOnResult.value(null) + return@rememberLauncherForActivityResult + } + val mimeType = resolver.getType(uri) + val extension = + when (mimeType) { + "image/png" -> "png" + "image/webp" -> "webp" + "image/heic" -> "heic" + "image/gif" -> "gif" + else -> "jpg" + } + currentOnResult.value(PickedImage(bytes = bytes, fileExtension = extension)) + } + return remember(launcher) { + { + launcher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + } +} diff --git a/client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.kt b/client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.kt new file mode 100644 index 00000000..72221d1e --- /dev/null +++ b/client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.kt @@ -0,0 +1,21 @@ +package com.plusmobileapps.chefmate.util + +import androidx.compose.runtime.Composable + +data class PickedImage(val bytes: ByteArray, val fileExtension: String) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PickedImage) return false + if (fileExtension != other.fileExtension) return false + if (!bytes.contentEquals(other.bytes)) return false + return true + } + + override fun hashCode(): Int = 31 * bytes.contentHashCode() + fileExtension.hashCode() +} + +/** + * Returns a launcher that opens the platform's image picker. The provided callback receives the + * picked image bytes and file extension, or `null` if the user cancelled. + */ +@Composable expect fun rememberImagePickerLauncher(onResult: (PickedImage?) -> Unit): () -> Unit diff --git a/client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.ios.kt b/client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.ios.kt new file mode 100644 index 00000000..12ea00f5 --- /dev/null +++ b/client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.ios.kt @@ -0,0 +1,106 @@ +@file:Suppress("ktlint:standard:filename") +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.plusmobileapps.chefmate.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.PhotosUI.PHPickerConfiguration +import platform.PhotosUI.PHPickerFilter +import platform.PhotosUI.PHPickerResult +import platform.PhotosUI.PHPickerViewController +import platform.PhotosUI.PHPickerViewControllerDelegateProtocol +import platform.UIKit.UIApplication +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowScene +import platform.darwin.NSObject +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import platform.posix.memcpy + +@Composable +actual fun rememberImagePickerLauncher(onResult: (PickedImage?) -> Unit): () -> Unit { + val currentOnResult = rememberUpdatedState(onResult) + return remember { + { + val configuration = + PHPickerConfiguration().apply { + selectionLimit = 1 + filter = PHPickerFilter.imagesFilter() + } + val picker = PHPickerViewController(configuration = configuration) + val delegate = ImagePickerDelegate { picked -> currentOnResult.value(picked) } + picker.delegate = delegate + retainedDelegates[picker] = delegate + topViewController()?.presentViewController(picker, animated = true, completion = null) + } + } +} + +private val retainedDelegates = mutableMapOf() + +private class ImagePickerDelegate(private val onResult: (PickedImage?) -> Unit) : + NSObject(), PHPickerViewControllerDelegateProtocol { + override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) { + picker.dismissViewControllerAnimated(true, completion = null) + retainedDelegates.remove(picker) + val result = didFinishPicking.firstOrNull() as? PHPickerResult + if (result == null) { + deliver(null) + return + } + val provider = result.itemProvider + val typeIdentifier = + when { + provider.hasItemConformingToTypeIdentifier("public.jpeg") -> "public.jpeg" + provider.hasItemConformingToTypeIdentifier("public.png") -> "public.png" + else -> "public.image" + } + provider.loadDataRepresentationForTypeIdentifier(typeIdentifier) { data, _ -> + val bytes = data?.toByteArray() + if (bytes == null) { + deliver(null) + } else { + val extension = + when (typeIdentifier) { + "public.png" -> "png" + else -> "jpg" + } + deliver(PickedImage(bytes = bytes, fileExtension = extension)) + } + } + } + + private fun deliver(result: PickedImage?) { + dispatch_async(dispatch_get_main_queue()) { onResult(result) } + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray { + val size = length.toInt() + if (size == 0) return ByteArray(0) + return ByteArray(size).also { array -> + array.usePinned { pinned -> memcpy(pinned.addressOf(0), this.bytes, length) } + } +} + +private fun topViewController(): UIViewController? { + val keyWindow = + UIApplication.sharedApplication.connectedScenes + .filterIsInstance() + .flatMap { it.windows.map { w -> w as UIWindow } } + .firstOrNull { it.isKeyWindow() } + var topVC: UIViewController? = keyWindow?.rootViewController + while (topVC?.presentedViewController != null) { + topVC = topVC.presentedViewController + } + return topVC +} diff --git a/client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.jvm.kt b/client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.jvm.kt new file mode 100644 index 00000000..05ba015c --- /dev/null +++ b/client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImagePickerUtil.jvm.kt @@ -0,0 +1,49 @@ +@file:Suppress("ktlint:standard:filename") + +package com.plusmobileapps.chefmate.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import javax.swing.JFileChooser +import javax.swing.SwingUtilities +import javax.swing.filechooser.FileNameExtensionFilter + +@Composable +actual fun rememberImagePickerLauncher(onResult: (PickedImage?) -> Unit): () -> Unit { + val currentOnResult = rememberUpdatedState(onResult) + return remember { + { + SwingUtilities.invokeLater { + val chooser = + JFileChooser().apply { + dialogTitle = "Select recipe photo" + fileFilter = + FileNameExtensionFilter( + "Images", + "jpg", + "jpeg", + "png", + "webp", + "gif", + "heic", + ) + } + val approved = chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION + if (!approved) { + currentOnResult.value(null) + return@invokeLater + } + val file = chooser.selectedFile + if (file == null || !file.canRead()) { + currentOnResult.value(null) + return@invokeLater + } + val extension = file.extension.ifBlank { "jpg" }.lowercase() + currentOnResult.value( + PickedImage(bytes = file.readBytes(), fileExtension = extension) + ) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a03ef18..5b27c0a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -121,6 +121,7 @@ sqldelight-dialect-sqlite335 = { group = "app.cash.sqldelight", name = "sqlite-3 supabase-client = { module = "io.github.jan-tennert.supabase:supabase-kt", version.ref = "supabase" } supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref = "supabase" } supabase-postgrest = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" } +supabase-storage = { module = "io.github.jan-tennert.supabase:storage-kt", version.ref = "supabase" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } [plugins] From 9106312360b5269cf8927f7a991c395f48bfad10 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 7 May 2026 23:48:36 -0700 Subject: [PATCH 2/9] docs: add Supabase Storage setup SQL for recipe photos Captures the bucket config and RLS policies that pair with the new SupabaseRecipePhotoStorage so the setup can be re-applied across environments. Co-Authored-By: Claude Opus 4.7 --- docs/supabase-storage-setup.sql | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/supabase-storage-setup.sql diff --git a/docs/supabase-storage-setup.sql b/docs/supabase-storage-setup.sql new file mode 100644 index 00000000..72e16653 --- /dev/null +++ b/docs/supabase-storage-setup.sql @@ -0,0 +1,55 @@ +-- Supabase Storage setup for recipe photo uploads. +-- Paste this into the Supabase SQL editor for each environment (dev, prod, ...). +-- Notes: +-- * storage.objects already has RLS enabled by default. +-- * `create policy` has no `if not exists`; drop a policy first if you need to re-run. +-- * The client uploads under "/." (see SupabaseRecipePhotoStorage.kt). + +-- 1. Create the bucket (public, 5 MB cap, image MIME types only). +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'recipe-photos', + 'recipe-photos', + true, + 5242880, -- 5 MB + array['image/jpeg', 'image/png', 'image/webp', 'image/heic'] +) +on conflict (id) do update set + public = excluded.public, + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +-- 2. Public read so Coil can load images via bucket.publicUrl(path). +create policy "recipe_photos_public_read" +on storage.objects for select +to public +using (bucket_id = 'recipe-photos'); + +-- 3. Authenticated users can only write inside their own "/" folder. +create policy "recipe_photos_owner_insert" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'recipe-photos' + and (storage.foldername(name))[1] = auth.uid()::text +); + +create policy "recipe_photos_owner_update" +on storage.objects for update +to authenticated +using ( + bucket_id = 'recipe-photos' + and (storage.foldername(name))[1] = auth.uid()::text +) +with check ( + bucket_id = 'recipe-photos' + and (storage.foldername(name))[1] = auth.uid()::text +); + +create policy "recipe_photos_owner_delete" +on storage.objects for delete +to authenticated +using ( + bucket_id = 'recipe-photos' + and (storage.foldername(name))[1] = auth.uid()::text +); From 6dd684e582772dc0941f120002a36906cbbfcf00 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Fri, 8 May 2026 00:12:53 -0700 Subject: [PATCH 3/9] refactor(recipe): defer photo upload until save Holds picked image bytes in the ViewModel and only hits Supabase Storage during save, so cancelled edits and replaced photos no longer leak orphaned files in the bucket. The local bytes drive the inline preview via Coil's ByteArray model, the dirty check treats a pending photo as unsaved changes, and an upload failure during save aborts the save and surfaces the existing error dialog instead of writing a half-saved row. Co-Authored-By: Claude Opus 4.7 --- .../core/impl/edit/EditRecipeBlocImpl.kt | 4 +- .../core/impl/edit/EditRecipeViewModel.kt | 52 ++++++++++++------- .../core/impl/edit/EditRecipeViewModelTest.kt | 48 +++++++++++++---- .../recipe/core/edit/EditRecipeBloc.kt | 3 +- .../recipe/core/edit/EditRecipeScreen.kt | 41 +++++++-------- 5 files changed, 93 insertions(+), 55 deletions(-) diff --git a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt index c961f201..652d4e83 100644 --- a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt +++ b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeBlocImpl.kt @@ -50,7 +50,6 @@ class EditRecipeBlocImpl( }, isLoading = it.isLoading, isSaving = it.isSaving, - isUploadingPhoto = it.isUploadingPhoto, showDiscardChangesDialog = it.showDiscardChangesDialog, uploadError = it.uploadError?.let { ResourceString(Res.string.edit_recipe_upload_failed) }, @@ -71,6 +70,7 @@ class EditRecipeBlocImpl( override val categories: StateFlow> = viewModel.categories override val availableUserCategories: StateFlow> = viewModel.availableUserCategories + override val pendingPhotoBytes: StateFlow = viewModel.pendingPhotoBytes init { scope.launch { @@ -177,7 +177,7 @@ class EditRecipeBlocImpl( } override fun onPhotoPicked(bytes: ByteArray, fileExtension: String) { - viewModel.uploadPhoto(bytes = bytes, fileExtension = fileExtension) + viewModel.setPendingPhoto(bytes = bytes, fileExtension = fileExtension) } override fun onUploadErrorDismissed() { diff --git a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt index ec5a7f82..f12897d4 100644 --- a/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt +++ b/client/recipe/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModel.kt @@ -94,6 +94,10 @@ class EditRecipeViewModel( // UI stuck on a spinner). private val _isCreatingCategory = MutableStateFlow(false) + private val _pendingPhotoBytes = MutableStateFlow(null) + val pendingPhotoBytes: StateFlow = _pendingPhotoBytes.asStateFlow() + private var pendingPhotoExtension: String? = null + init { when { recipeId != null -> { @@ -253,19 +257,10 @@ class EditRecipeViewModel( _state.update { it.copy(showDiscardChangesDialog = false) } } - fun uploadPhoto(bytes: ByteArray, fileExtension: String) { - if (_state.value.isUploadingPhoto) return - _state.update { it.copy(isUploadingPhoto = true, uploadError = null) } - scope.launch { - try { - val url = photoStorage.uploadPhoto(bytes = bytes, fileExtension = fileExtension) - _imageUrl.value = url - _state.update { it.copy(isUploadingPhoto = false) } - } catch (t: Throwable) { - Logger.e(throwable = t, tag = "EditRecipeViewModel") { "Failed to upload photo" } - _state.update { it.copy(isUploadingPhoto = false, uploadError = t) } - } - } + fun setPendingPhoto(bytes: ByteArray, fileExtension: String) { + _pendingPhotoBytes.value = bytes + pendingPhotoExtension = fileExtension + _state.update { it.copy(uploadError = null) } } fun dismissUploadError() { @@ -273,10 +268,28 @@ class EditRecipeViewModel( } 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) @@ -317,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() || @@ -335,6 +349,7 @@ class EditRecipeViewModel( currentRecipe.starRating != null || currentRecipe.categories.isNotEmpty() } + } private fun Recipe.isDirty(): Boolean = title != _title.value || @@ -375,7 +390,6 @@ class EditRecipeViewModel( data class State( val isLoading: Boolean = false, val isSaving: Boolean = false, - val isUploadingPhoto: Boolean = false, val showDiscardChangesDialog: Boolean = false, val recipe: Recipe? = null, val uploadError: Throwable? = null, diff --git a/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt b/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt index 8cf550a4..6c9779ec 100644 --- a/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt +++ b/client/recipe/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/core/impl/edit/EditRecipeViewModelTest.kt @@ -410,35 +410,63 @@ class EditRecipeViewModelTest { } @Test - fun When_photo_uploaded_Then_image_url_is_updated() = runTest { + 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(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.uploadPhoto(bytes = byteArrayOf(1, 2, 3), fileExtension = "jpg") + vm.save() + val output = vm.output.first() - vm.imageUrl.value shouldBe "https://cdn.example.com/photo.jpg" - vm.state.value.isUploadingPhoto shouldBe false - vm.state.value.uploadError shouldBe null + output.shouldBeFinished() photoStorage.uploads.size shouldBe 1 - photoStorage.uploads.first().fileExtension shouldBe "jpg" + 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_photo_upload_fails_Then_error_is_surfaced_and_url_is_unchanged() = runTest { + 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.uploadPhoto(bytes = byteArrayOf(0), fileExtension = "png") + vm.save() - vm.imageUrl.value shouldBe "" - vm.state.value.isUploadingPhoto shouldBe false + recipes.value shouldBe emptyList() + vm.state.value.isSaving shouldBe false vm.state.value.uploadError shouldBe failure + vm.pendingPhotoBytes.value?.toList() shouldBe listOf(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) } diff --git a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt index ae7af0fe..3fd088a3 100644 --- a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt @@ -41,6 +41,8 @@ interface EditRecipeBloc : BackClickBloc { /** User-created categories from the local DB. Built-in presets are not included. */ val availableUserCategories: StateFlow> + val pendingPhotoBytes: StateFlow + fun onTitleChanged(title: String) fun onDescriptionChanged(description: String) @@ -110,7 +112,6 @@ interface EditRecipeBloc : BackClickBloc { val title: TextData, val isLoading: Boolean, val isSaving: Boolean, - val isUploadingPhoto: Boolean, val showDiscardChangesDialog: Boolean, val uploadError: TextData? = null, ) diff --git a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt index 1ca0598f..da4dfd39 100644 --- a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt @@ -38,6 +38,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chefmate.client.recipe.core.public.generated.resources.Res @@ -83,12 +85,12 @@ import chefmate.client.recipe.core.public.generated.resources.edit_recipe_field_ import chefmate.client.recipe.core.public.generated.resources.edit_recipe_save import chefmate.client.recipe.core.public.generated.resources.edit_recipe_upload_photo import chefmate.client.recipe.core.public.generated.resources.edit_recipe_upload_photo_dismiss +import coil3.compose.AsyncImage import com.plusmobileapps.chefmate.recipe.data.BuiltinCategory import com.plusmobileapps.chefmate.text.FixedString import com.plusmobileapps.chefmate.ui.components.PlusHeaderContainer import com.plusmobileapps.chefmate.ui.components.PlusHeaderData import com.plusmobileapps.chefmate.ui.components.PlusLoadingIndicator -import com.plusmobileapps.chefmate.ui.components.RecipeImage import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme import com.plusmobileapps.chefmate.util.rememberImagePickerLauncher import kotlinx.coroutines.flow.MutableStateFlow @@ -340,8 +342,8 @@ private fun RecipeStarRatingField(bloc: EditRecipeBloc, modifier: Modifier = Mod @Composable private fun RecipePhotoUploader(bloc: EditRecipeBloc, modifier: Modifier = Modifier) { - val state by bloc.state.collectAsState() val imageUrl by bloc.imageUrl.collectAsState() + val pendingBytes by bloc.pendingPhotoBytes.collectAsState() val pickPhoto = rememberImagePickerLauncher { picked -> if (picked != null) { bloc.onPhotoPicked(picked.bytes, picked.fileExtension) @@ -352,29 +354,22 @@ private fun RecipePhotoUploader(bloc: EditRecipeBloc, modifier: Modifier = Modif modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), ) { - if (imageUrl.isNotBlank()) { - RecipeImage( - imageUrl = imageUrl, + val previewModel: Any? = pendingBytes ?: imageUrl.takeIf { it.isNotBlank() } + if (previewModel != null) { + AsyncImage( + model = previewModel, contentDescription = null, - modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + modifier = + Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop, ) } - OutlinedButton( - onClick = pickPhoto, - enabled = !state.isUploadingPhoto, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.isUploadingPhoto) { - PlusLoadingIndicator( - modifier = Modifier.padding(end = ChefMateTheme.dimens.paddingSmall) - ) - } else { - Icon( - imageVector = Icons.Filled.AddPhotoAlternate, - contentDescription = null, - modifier = Modifier.padding(end = ChefMateTheme.dimens.paddingSmall), - ) - } + OutlinedButton(onClick = pickPhoto, modifier = Modifier.fillMaxWidth()) { + Icon( + imageVector = Icons.Filled.AddPhotoAlternate, + contentDescription = null, + modifier = Modifier.padding(end = ChefMateTheme.dimens.paddingSmall), + ) Text(stringResource(Res.string.edit_recipe_upload_photo)) } } @@ -560,7 +555,6 @@ private val previewBloc = title = FixedString("Edit Recipe"), isLoading = false, isSaving = false, - isUploadingPhoto = false, showDiscardChangesDialog = false, ) ) @@ -613,6 +607,7 @@ Salt for pasta water""" override val availableUserCategories: StateFlow> = MutableStateFlow(emptyList()) + override val pendingPhotoBytes: StateFlow = MutableStateFlow(null) override fun onTitleChanged(title: String) {} From d1dd58041747c91cd7e15327c44d2d4f56154f5b Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Fri, 8 May 2026 12:23:35 -0700 Subject: [PATCH 4/9] feat(recipe): allow photo uploads while signed out Falls back to a shared "anonymous/" folder when no user is signed in so the upload flow works without an account, matching how recipes themselves are already created locally without auth. Adds a matching RLS policy for the anon role that scopes their writes to that folder. Co-Authored-By: Claude Opus 4.7 --- .../data/impl/remote/SupabaseRecipePhotoStorage.kt | 7 ++++--- docs/supabase-storage-setup.sql | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt index 0bc90e91..72aa31ed 100644 --- a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt +++ b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt @@ -23,17 +23,18 @@ class SupabaseRecipePhotoStorage( ) : RecipePhotoStorage { override suspend fun uploadPhoto(bytes: ByteArray, fileExtension: String): String { - val ownerId = + val ownerFolder = (authRepository.state.value as? AuthState.Authenticated)?.user?.userId - ?: error("Cannot upload recipe photo while signed out") + ?: ANONYMOUS_FOLDER val bucket = supabaseClient.storage.from(BUCKET_NAME) val sanitizedExtension = fileExtension.trimStart('.').lowercase().ifBlank { "jpg" } - val path = "$ownerId/${Uuid.random()}.$sanitizedExtension" + val path = "$ownerFolder/${Uuid.random()}.$sanitizedExtension" bucket.upload(path = path, data = bytes) { upsert = false } return bucket.publicUrl(path) } private companion object { const val BUCKET_NAME = "recipe-photos" + const val ANONYMOUS_FOLDER = "anonymous" } } diff --git a/docs/supabase-storage-setup.sql b/docs/supabase-storage-setup.sql index 72e16653..88513af3 100644 --- a/docs/supabase-storage-setup.sql +++ b/docs/supabase-storage-setup.sql @@ -34,6 +34,18 @@ with check ( and (storage.foldername(name))[1] = auth.uid()::text ); +-- 3a. Signed-out users can write into the shared "anonymous/" folder. +-- Note: this lets anyone with the anon key upload up to 5 MB at a time. +-- The bucket's file_size_limit + allowed_mime_types are the only abuse +-- guardrails; tighten or remove this policy if quota abuse becomes an issue. +create policy "recipe_photos_anon_insert" +on storage.objects for insert +to anon +with check ( + bucket_id = 'recipe-photos' + and (storage.foldername(name))[1] = 'anonymous' +); + create policy "recipe_photos_owner_update" on storage.objects for update to authenticated From eecc21a8aa0f62fe4c730b017dad9fe1ec751fae Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Fri, 8 May 2026 12:49:10 -0700 Subject: [PATCH 5/9] feat(recipe): user-driven 1:1 crop and cascade-delete photos Adds a full-screen Compose crop overlay that opens after the picker returns. The user pans/zooms the image behind a fixed centered 1:1 frame and confirms; the resulting source rectangle is cropped and downscaled to 1024px via Bitmap on Android and Skia on iOS/JVM before the bytes land in the ViewModel for upload. Also adds an after-delete trigger on the recipes table that pulls the storage path out of image_url and removes the matching object, so deleting a recipe no longer leaves an orphaned photo in the recipe-photos bucket. Co-Authored-By: Claude Opus 4.7 --- .../composeResources/values/strings.xml | 3 + .../recipe/core/edit/CropPhotoOverlay.kt | 172 ++++++++++++++++++ .../recipe/core/edit/EditRecipeScreen.kt | 54 +++++- .../util/ImageProcessingUtil.android.kt | 31 ++++ .../chefmate/util/ImageProcessingUtil.kt | 18 ++ .../chefmate/util/ImageProcessingUtil.ios.kt | 43 +++++ .../chefmate/util/ImageProcessingUtil.jvm.kt | 35 ++++ docs/supabase-storage-setup.sql | 29 +++ 8 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt create mode 100644 client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.android.kt create mode 100644 client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.kt create mode 100644 client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.ios.kt create mode 100644 client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.jvm.kt diff --git a/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml b/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml index 5c2da438..3c79c602 100644 --- a/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml +++ b/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml @@ -71,6 +71,9 @@ Enter image URL Upload photo OK + Crop photo + Cancel + Crop Source URL Enter source URL Servings diff --git a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt new file mode 100644 index 00000000..81530130 --- /dev/null +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt @@ -0,0 +1,172 @@ +package com.plusmobileapps.chefmate.recipe.core.edit + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import chefmate.client.recipe.core.public.generated.resources.Res +import chefmate.client.recipe.core.public.generated.resources.edit_recipe_crop_cancel +import chefmate.client.recipe.core.public.generated.resources.edit_recipe_crop_confirm +import chefmate.client.recipe.core.public.generated.resources.edit_recipe_crop_title +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme +import kotlin.math.max +import kotlin.math.min +import org.jetbrains.compose.resources.stringResource + +private const val MAX_USER_ZOOM = 4f + +@Composable +fun CropPhotoOverlay( + bitmap: ImageBitmap, + isProcessing: Boolean, + onCancel: () -> Unit, + onConfirm: (srcX: Int, srcY: Int, srcSize: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val imgW = bitmap.width + val imgH = bitmap.height + var userScale by remember(bitmap) { mutableStateOf(1f) } + var userOffset by remember(bitmap) { mutableStateOf(Offset.Zero) } + + BoxWithConstraints( + modifier = modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center, + ) { + val frameSidePx = (min(constraints.maxWidth, constraints.maxHeight) * 0.85f).toInt() + val baseScale = + max(frameSidePx.toFloat() / imgW.toFloat(), frameSidePx.toFloat() / imgH.toFloat()) + val effectiveScale = baseScale * userScale + + Canvas( + modifier = + Modifier.fillMaxSize().pointerInput(bitmap) { + detectTransformGestures { _, pan, zoom, _ -> + val nextScale = (userScale * zoom).coerceIn(1f, MAX_USER_ZOOM) + val nextEffective = baseScale * nextScale + val nextRenderedW = imgW * nextEffective + val nextRenderedH = imgH * nextEffective + val nextMaxX = max(0f, (nextRenderedW - frameSidePx) / 2f) + val nextMaxY = max(0f, (nextRenderedH - frameSidePx) / 2f) + userScale = nextScale + userOffset = + Offset( + (userOffset.x + pan.x).coerceIn(-nextMaxX, nextMaxX), + (userOffset.y + pan.y).coerceIn(-nextMaxY, nextMaxY), + ) + } + } + ) { + val canvasCenter = Offset(size.width / 2f, size.height / 2f) + val renderedW = imgW * effectiveScale + val renderedH = imgH * effectiveScale + val imageTopLeft = canvasCenter + userOffset - Offset(renderedW / 2f, renderedH / 2f) + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = IntSize(imgW, imgH), + dstOffset = IntOffset(imageTopLeft.x.toInt(), imageTopLeft.y.toInt()), + dstSize = IntSize(renderedW.toInt(), renderedH.toInt()), + ) + + val frameSizeF = frameSidePx.toFloat() + val frameTopLeft = canvasCenter - Offset(frameSizeF / 2f, frameSizeF / 2f) + val dim = Color.Black.copy(alpha = 0.55f) + drawRect(dim, Offset.Zero, Size(size.width, frameTopLeft.y)) + drawRect( + dim, + Offset(0f, frameTopLeft.y + frameSizeF), + Size(size.width, size.height - (frameTopLeft.y + frameSizeF)), + ) + drawRect(dim, Offset(0f, frameTopLeft.y), Size(frameTopLeft.x, frameSizeF)) + drawRect( + dim, + Offset(frameTopLeft.x + frameSizeF, frameTopLeft.y), + Size(size.width - (frameTopLeft.x + frameSizeF), frameSizeF), + ) + drawRect( + color = Color.White, + topLeft = frameTopLeft, + size = Size(frameSizeF, frameSizeF), + style = Stroke(width = 2.dp.toPx()), + ) + } + + Text( + text = stringResource(Res.string.edit_recipe_crop_title), + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier.align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(ChefMateTheme.dimens.paddingNormal), + ) + + Row( + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(ChefMateTheme.dimens.paddingNormal), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton(onClick = onCancel, enabled = !isProcessing) { + Text(text = stringResource(Res.string.edit_recipe_crop_cancel), color = Color.White) + } + Button( + enabled = !isProcessing, + onClick = { + val srcSizeF = frameSidePx.toFloat() / effectiveScale + val srcSizeInt = srcSizeF.toInt().coerceIn(1, min(imgW, imgH)) + val srcCenterX = imgW / 2f - userOffset.x / effectiveScale + val srcCenterY = imgH / 2f - userOffset.y / effectiveScale + val srcX = (srcCenterX - srcSizeInt / 2f).toInt().coerceIn(0, imgW - srcSizeInt) + val srcY = (srcCenterY - srcSizeInt / 2f).toInt().coerceIn(0, imgH - srcSizeInt) + onConfirm(srcX, srcY, srcSizeInt) + }, + ) { + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(ChefMateTheme.dimens.paddingSmall)) + } + Text(stringResource(Res.string.edit_recipe_crop_confirm)) + } + } + } +} diff --git a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt index da4dfd39..f9f54965 100644 --- a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeScreen.kt @@ -34,11 +34,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -92,9 +95,14 @@ import com.plusmobileapps.chefmate.ui.components.PlusHeaderContainer import com.plusmobileapps.chefmate.ui.components.PlusHeaderData import com.plusmobileapps.chefmate.ui.components.PlusLoadingIndicator import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme +import com.plusmobileapps.chefmate.util.cropImageToSquare +import com.plusmobileapps.chefmate.util.decodeImageBitmap import com.plusmobileapps.chefmate.util.rememberImagePickerLauncher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @@ -340,13 +348,27 @@ private fun RecipeStarRatingField(bloc: EditRecipeBloc, modifier: Modifier = Mod } } +private data class PendingCrop(val bytes: ByteArray, val bitmap: ImageBitmap) + @Composable private fun RecipePhotoUploader(bloc: EditRecipeBloc, modifier: Modifier = Modifier) { val imageUrl by bloc.imageUrl.collectAsState() val pendingBytes by bloc.pendingPhotoBytes.collectAsState() + val scope = rememberCoroutineScope() + var pendingCrop by remember { mutableStateOf(null) } + var isCropping by remember { mutableStateOf(false) } val pickPhoto = rememberImagePickerLauncher { picked -> if (picked != null) { - bloc.onPhotoPicked(picked.bytes, picked.fileExtension) + scope.launch { + val bitmap = + runCatching { + withContext(Dispatchers.Default) { decodeImageBitmap(picked.bytes) } + } + .getOrNull() + if (bitmap != null) { + pendingCrop = PendingCrop(bytes = picked.bytes, bitmap = bitmap) + } + } } } @@ -360,7 +382,7 @@ private fun RecipePhotoUploader(bloc: EditRecipeBloc, modifier: Modifier = Modif model = previewModel, contentDescription = null, modifier = - Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium), + Modifier.fillMaxWidth().aspectRatio(1f).clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop, ) } @@ -373,6 +395,34 @@ private fun RecipePhotoUploader(bloc: EditRecipeBloc, modifier: Modifier = Modif Text(stringResource(Res.string.edit_recipe_upload_photo)) } } + + pendingCrop?.let { crop -> + CropPhotoOverlay( + bitmap = crop.bitmap, + isProcessing = isCropping, + onCancel = { pendingCrop = null }, + onConfirm = { srcX, srcY, srcSize -> + isCropping = true + scope.launch { + try { + val cropped = + withContext(Dispatchers.Default) { + cropImageToSquare( + bytes = crop.bytes, + srcX = srcX, + srcY = srcY, + srcSize = srcSize, + ) + } + bloc.onPhotoPicked(cropped, "jpg") + } finally { + isCropping = false + pendingCrop = null + } + } + }, + ) + } } @Composable diff --git a/client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.android.kt b/client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.android.kt new file mode 100644 index 00000000..597018da --- /dev/null +++ b/client/util/public/src/androidMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.android.kt @@ -0,0 +1,31 @@ +@file:Suppress("ktlint:standard:filename") + +package com.plusmobileapps.chefmate.util + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import java.io.ByteArrayOutputStream +import kotlin.math.min + +actual fun decodeImageBitmap(bytes: ByteArray): ImageBitmap = + BitmapFactory.decodeByteArray(bytes, 0, bytes.size).asImageBitmap() + +actual fun cropImageToSquare( + bytes: ByteArray, + srcX: Int, + srcY: Int, + srcSize: Int, + maxOutputDim: Int, +): ByteArray { + val src = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + val cropped = Bitmap.createBitmap(src, srcX, srcY, srcSize, srcSize) + val finalSize = min(srcSize, maxOutputDim) + val output = + if (finalSize == srcSize) cropped + else Bitmap.createScaledBitmap(cropped, finalSize, finalSize, true) + val baos = ByteArrayOutputStream() + output.compress(Bitmap.CompressFormat.JPEG, 90, baos) + return baos.toByteArray() +} diff --git a/client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.kt b/client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.kt new file mode 100644 index 00000000..9e86bed3 --- /dev/null +++ b/client/util/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.kt @@ -0,0 +1,18 @@ +package com.plusmobileapps.chefmate.util + +import androidx.compose.ui.graphics.ImageBitmap + +/** Decodes encoded image bytes (JPEG/PNG/etc.) into a Compose [ImageBitmap]. */ +expect fun decodeImageBitmap(bytes: ByteArray): ImageBitmap + +/** + * Crops the encoded image to a square sub-region and re-encodes as JPEG. The output is also + * downscaled to [maxOutputDim] on each side when the source square is larger. + */ +expect fun cropImageToSquare( + bytes: ByteArray, + srcX: Int, + srcY: Int, + srcSize: Int, + maxOutputDim: Int = 1024, +): ByteArray diff --git a/client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.ios.kt b/client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.ios.kt new file mode 100644 index 00000000..5c3a54fc --- /dev/null +++ b/client/util/public/src/iosMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.ios.kt @@ -0,0 +1,43 @@ +@file:Suppress("ktlint:standard:filename") + +package com.plusmobileapps.chefmate.util + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlin.math.min +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image +import org.jetbrains.skia.Rect +import org.jetbrains.skia.Surface + +actual fun decodeImageBitmap(bytes: ByteArray): ImageBitmap = + Image.makeFromEncoded(bytes).toComposeImageBitmap() + +actual fun cropImageToSquare( + bytes: ByteArray, + srcX: Int, + srcY: Int, + srcSize: Int, + maxOutputDim: Int, +): ByteArray = skikoCropToSquare(bytes, srcX, srcY, srcSize, maxOutputDim) + +internal fun skikoCropToSquare( + bytes: ByteArray, + srcX: Int, + srcY: Int, + srcSize: Int, + maxOutputDim: Int, +): ByteArray { + val image = Image.makeFromEncoded(bytes) + val outputSize = min(srcSize, maxOutputDim) + val surface = Surface.makeRasterN32Premul(outputSize, outputSize) + val canvas = surface.canvas + canvas.drawImageRect( + image, + Rect.makeXYWH(srcX.toFloat(), srcY.toFloat(), srcSize.toFloat(), srcSize.toFloat()), + Rect.makeXYWH(0f, 0f, outputSize.toFloat(), outputSize.toFloat()), + ) + val cropped = surface.makeImageSnapshot() + val data = cropped.encodeToData(EncodedImageFormat.JPEG, 90) ?: error("JPEG encode failed") + return data.bytes +} diff --git a/client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.jvm.kt b/client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.jvm.kt new file mode 100644 index 00000000..c586e665 --- /dev/null +++ b/client/util/public/src/jvmMain/kotlin/com/plusmobileapps/chefmate/util/ImageProcessingUtil.jvm.kt @@ -0,0 +1,35 @@ +@file:Suppress("ktlint:standard:filename") + +package com.plusmobileapps.chefmate.util + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlin.math.min +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image +import org.jetbrains.skia.Rect +import org.jetbrains.skia.Surface + +actual fun decodeImageBitmap(bytes: ByteArray): ImageBitmap = + Image.makeFromEncoded(bytes).toComposeImageBitmap() + +actual fun cropImageToSquare( + bytes: ByteArray, + srcX: Int, + srcY: Int, + srcSize: Int, + maxOutputDim: Int, +): ByteArray { + val image = Image.makeFromEncoded(bytes) + val outputSize = min(srcSize, maxOutputDim) + val surface = Surface.makeRasterN32Premul(outputSize, outputSize) + val canvas = surface.canvas + canvas.drawImageRect( + image, + Rect.makeXYWH(srcX.toFloat(), srcY.toFloat(), srcSize.toFloat(), srcSize.toFloat()), + Rect.makeXYWH(0f, 0f, outputSize.toFloat(), outputSize.toFloat()), + ) + val cropped = surface.makeImageSnapshot() + val data = cropped.encodeToData(EncodedImageFormat.JPEG, 90) ?: error("JPEG encode failed") + return data.bytes +} diff --git a/docs/supabase-storage-setup.sql b/docs/supabase-storage-setup.sql index 88513af3..bc146896 100644 --- a/docs/supabase-storage-setup.sql +++ b/docs/supabase-storage-setup.sql @@ -65,3 +65,32 @@ using ( bucket_id = 'recipe-photos' and (storage.foldername(name))[1] = auth.uid()::text ); + +-- 4. Cascade-delete the recipe's photo from storage when the recipe row is deleted. +-- Parses the storage path out of image_url; URLs pointing to other domains +-- (e.g. manually-typed image URLs) are left alone. `security definer` is needed +-- so the function runs as the owner role, which has delete rights on storage.objects. +create or replace function public.delete_recipe_photo() +returns trigger +language plpgsql +security definer +set search_path = public, storage +as $$ +declare + storage_path text; +begin + if old.image_url is not null and old.image_url like '%/recipe-photos/%' then + storage_path := substring(old.image_url from '/recipe-photos/(.+)$'); + if storage_path is not null and length(storage_path) > 0 then + delete from storage.objects + where bucket_id = 'recipe-photos' and name = storage_path; + end if; + end if; + return old; +end; +$$; + +drop trigger if exists recipes_delete_photo on public.recipes; +create trigger recipes_delete_photo +after delete on public.recipes +for each row execute function public.delete_recipe_photo(); From 3fc076a0beae9bcae2aec12cd3ba16107431ff55 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Fri, 8 May 2026 12:56:30 -0700 Subject: [PATCH 6/9] refactor(recipe): host crop overlay in a Compose Dialog Wraps the crop UI in androidx.compose.ui.window.Dialog so it lives in its own window: back press dismisses just the crop step (not the whole edit screen), touches don't bleed through to the form behind, and the overlay is a real system-level modal rather than an inline overlay. Co-Authored-By: Claude Opus 4.7 --- .../recipe/core/edit/CropPhotoOverlay.kt | 210 ++++++++++-------- 1 file changed, 114 insertions(+), 96 deletions(-) diff --git a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt index 81530130..d3f9739b 100644 --- a/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import chefmate.client.recipe.core.public.generated.resources.Res import chefmate.client.recipe.core.public.generated.resources.edit_recipe_crop_cancel import chefmate.client.recipe.core.public.generated.resources.edit_recipe_crop_confirm @@ -60,112 +62,128 @@ fun CropPhotoOverlay( var userScale by remember(bitmap) { mutableStateOf(1f) } var userOffset by remember(bitmap) { mutableStateOf(Offset.Zero) } - BoxWithConstraints( - modifier = modifier.fillMaxSize().background(Color.Black), - contentAlignment = Alignment.Center, + Dialog( + onDismissRequest = { if (!isProcessing) onCancel() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), ) { - val frameSidePx = (min(constraints.maxWidth, constraints.maxHeight) * 0.85f).toInt() - val baseScale = - max(frameSidePx.toFloat() / imgW.toFloat(), frameSidePx.toFloat() / imgH.toFloat()) - val effectiveScale = baseScale * userScale + BoxWithConstraints( + modifier = modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center, + ) { + val frameSidePx = (min(constraints.maxWidth, constraints.maxHeight) * 0.85f).toInt() + val baseScale = + max(frameSidePx.toFloat() / imgW.toFloat(), frameSidePx.toFloat() / imgH.toFloat()) + val effectiveScale = baseScale * userScale - Canvas( - modifier = - Modifier.fillMaxSize().pointerInput(bitmap) { - detectTransformGestures { _, pan, zoom, _ -> - val nextScale = (userScale * zoom).coerceIn(1f, MAX_USER_ZOOM) - val nextEffective = baseScale * nextScale - val nextRenderedW = imgW * nextEffective - val nextRenderedH = imgH * nextEffective - val nextMaxX = max(0f, (nextRenderedW - frameSidePx) / 2f) - val nextMaxY = max(0f, (nextRenderedH - frameSidePx) / 2f) - userScale = nextScale - userOffset = - Offset( - (userOffset.x + pan.x).coerceIn(-nextMaxX, nextMaxX), - (userOffset.y + pan.y).coerceIn(-nextMaxY, nextMaxY), - ) + Canvas( + modifier = + Modifier.fillMaxSize().pointerInput(bitmap) { + detectTransformGestures { _, pan, zoom, _ -> + val nextScale = (userScale * zoom).coerceIn(1f, MAX_USER_ZOOM) + val nextEffective = baseScale * nextScale + val nextRenderedW = imgW * nextEffective + val nextRenderedH = imgH * nextEffective + val nextMaxX = max(0f, (nextRenderedW - frameSidePx) / 2f) + val nextMaxY = max(0f, (nextRenderedH - frameSidePx) / 2f) + userScale = nextScale + userOffset = + Offset( + (userOffset.x + pan.x).coerceIn(-nextMaxX, nextMaxX), + (userOffset.y + pan.y).coerceIn(-nextMaxY, nextMaxY), + ) + } } - } - ) { - val canvasCenter = Offset(size.width / 2f, size.height / 2f) - val renderedW = imgW * effectiveScale - val renderedH = imgH * effectiveScale - val imageTopLeft = canvasCenter + userOffset - Offset(renderedW / 2f, renderedH / 2f) - drawImage( - image = bitmap, - srcOffset = IntOffset.Zero, - srcSize = IntSize(imgW, imgH), - dstOffset = IntOffset(imageTopLeft.x.toInt(), imageTopLeft.y.toInt()), - dstSize = IntSize(renderedW.toInt(), renderedH.toInt()), - ) + ) { + val canvasCenter = Offset(size.width / 2f, size.height / 2f) + val renderedW = imgW * effectiveScale + val renderedH = imgH * effectiveScale + val imageTopLeft = + canvasCenter + userOffset - Offset(renderedW / 2f, renderedH / 2f) + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = IntSize(imgW, imgH), + dstOffset = IntOffset(imageTopLeft.x.toInt(), imageTopLeft.y.toInt()), + dstSize = IntSize(renderedW.toInt(), renderedH.toInt()), + ) - val frameSizeF = frameSidePx.toFloat() - val frameTopLeft = canvasCenter - Offset(frameSizeF / 2f, frameSizeF / 2f) - val dim = Color.Black.copy(alpha = 0.55f) - drawRect(dim, Offset.Zero, Size(size.width, frameTopLeft.y)) - drawRect( - dim, - Offset(0f, frameTopLeft.y + frameSizeF), - Size(size.width, size.height - (frameTopLeft.y + frameSizeF)), - ) - drawRect(dim, Offset(0f, frameTopLeft.y), Size(frameTopLeft.x, frameSizeF)) - drawRect( - dim, - Offset(frameTopLeft.x + frameSizeF, frameTopLeft.y), - Size(size.width - (frameTopLeft.x + frameSizeF), frameSizeF), - ) - drawRect( + val frameSizeF = frameSidePx.toFloat() + val frameTopLeft = canvasCenter - Offset(frameSizeF / 2f, frameSizeF / 2f) + val dim = Color.Black.copy(alpha = 0.55f) + drawRect(dim, Offset.Zero, Size(size.width, frameTopLeft.y)) + drawRect( + dim, + Offset(0f, frameTopLeft.y + frameSizeF), + Size(size.width, size.height - (frameTopLeft.y + frameSizeF)), + ) + drawRect(dim, Offset(0f, frameTopLeft.y), Size(frameTopLeft.x, frameSizeF)) + drawRect( + dim, + Offset(frameTopLeft.x + frameSizeF, frameTopLeft.y), + Size(size.width - (frameTopLeft.x + frameSizeF), frameSizeF), + ) + drawRect( + color = Color.White, + topLeft = frameTopLeft, + size = Size(frameSizeF, frameSizeF), + style = Stroke(width = 2.dp.toPx()), + ) + } + + Text( + text = stringResource(Res.string.edit_recipe_crop_title), color = Color.White, - topLeft = frameTopLeft, - size = Size(frameSizeF, frameSizeF), - style = Stroke(width = 2.dp.toPx()), + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier.align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(ChefMateTheme.dimens.paddingNormal), ) - } - Text( - text = stringResource(Res.string.edit_recipe_crop_title), - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = - Modifier.align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(ChefMateTheme.dimens.paddingNormal), - ) - - Row( - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(ChefMateTheme.dimens.paddingNormal), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextButton(onClick = onCancel, enabled = !isProcessing) { - Text(text = stringResource(Res.string.edit_recipe_crop_cancel), color = Color.White) - } - Button( - enabled = !isProcessing, - onClick = { - val srcSizeF = frameSidePx.toFloat() / effectiveScale - val srcSizeInt = srcSizeF.toInt().coerceIn(1, min(imgW, imgH)) - val srcCenterX = imgW / 2f - userOffset.x / effectiveScale - val srcCenterY = imgH / 2f - userOffset.y / effectiveScale - val srcX = (srcCenterX - srcSizeInt / 2f).toInt().coerceIn(0, imgW - srcSizeInt) - val srcY = (srcCenterY - srcSizeInt / 2f).toInt().coerceIn(0, imgH - srcSizeInt) - onConfirm(srcX, srcY, srcSizeInt) - }, + Row( + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(ChefMateTheme.dimens.paddingNormal), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - if (isProcessing) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, + TextButton(onClick = onCancel, enabled = !isProcessing) { + Text( + text = stringResource(Res.string.edit_recipe_crop_cancel), + color = Color.White, ) - Spacer(Modifier.width(ChefMateTheme.dimens.paddingSmall)) } - Text(stringResource(Res.string.edit_recipe_crop_confirm)) + Button( + enabled = !isProcessing, + onClick = { + val srcSizeF = frameSidePx.toFloat() / effectiveScale + val srcSizeInt = srcSizeF.toInt().coerceIn(1, min(imgW, imgH)) + val srcCenterX = imgW / 2f - userOffset.x / effectiveScale + val srcCenterY = imgH / 2f - userOffset.y / effectiveScale + val srcX = + (srcCenterX - srcSizeInt / 2f).toInt().coerceIn(0, imgW - srcSizeInt) + val srcY = + (srcCenterY - srcSizeInt / 2f).toInt().coerceIn(0, imgH - srcSizeInt) + onConfirm(srcX, srcY, srcSizeInt) + }, + ) { + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(ChefMateTheme.dimens.paddingSmall)) + } + Text(stringResource(Res.string.edit_recipe_crop_confirm)) + } } } } From ec9aff3df880f382e42aa421506615a45b526ee0 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Sun, 10 May 2026 14:17:19 -0700 Subject: [PATCH 7/9] feat(recipe): clean up photos on local deletes and photo swaps Adds RecipePhotoStorage.deletePhoto() and wires RecipeRepositoryImpl to call it during deleteRecipe (covers the signed-out / never-synced case where no remote row exists for the Postgres trigger to fire on) and during updateRecipe whenever image_url changes (covers replacing a photo on a recipe that hasn't synced yet). Also adds an after-update trigger on recipes that mirrors the existing delete trigger, so direct admin edits or other clients still clean up the previous photo when image_url is swapped. Co-Authored-By: Claude Opus 4.7 --- .../recipe/data/impl/RecipeRepositoryImpl.kt | 15 +++++++-- .../impl/remote/SupabaseRecipePhotoStorage.kt | 15 +++++++++ .../recipe/data/RecipePhotoStorage.kt | 6 ++++ .../data/testing/FakeRecipePhotoStorage.kt | 5 +++ docs/supabase-storage-setup.sql | 31 +++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt index 4eb07dc2..fa5b2393 100644 --- a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt +++ b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt @@ -12,6 +12,7 @@ import com.plusmobileapps.chefmate.di.IO import com.plusmobileapps.chefmate.recipe.data.BuiltinCategory import com.plusmobileapps.chefmate.recipe.data.Category import com.plusmobileapps.chefmate.recipe.data.Recipe +import com.plusmobileapps.chefmate.recipe.data.RecipePhotoStorage import com.plusmobileapps.chefmate.recipe.data.RecipeRepository import com.plusmobileapps.chefmate.recipe.data.SyncStatus import com.plusmobileapps.chefmate.recipe.data.impl.remote.RecipeRemoteDataSource @@ -49,6 +50,7 @@ class RecipeRepositoryImpl( private val dateTimeUtil: DateTimeUtil, private val remoteDataSource: RecipeRemoteDataSource, private val authRepository: AuthenticationRepository, + private val photoStorage: RecipePhotoStorage, ) : RecipeRepository { private val scope = CoroutineScope(ioContext + SupervisorJob()) @@ -116,10 +118,11 @@ class RecipeRepositoryImpl( } override suspend fun updateRecipe(recipe: Recipe): Recipe { - val result = + val (result, previousImageUrl) = withContext(ioContext) { val now = dateTimeUtil.now - db.transactionWithResult { + val previous = db.getById(recipe.id).executeAsOneOrNull() + val updated = db.transactionWithResult { db.update( id = recipe.id, title = recipe.title, @@ -140,7 +143,11 @@ class RecipeRepositoryImpl( syncJoinRowsForRecipe(recipe.id, recipe.categories) recipe.copy(updatedAt = now) } + updated to previous?.imageUrl } + if (!previousImageUrl.isNullOrBlank() && previousImageUrl != recipe.imageUrl) { + scope.launch { photoStorage.deletePhoto(previousImageUrl) } + } pushUpdateToRemote(recipe.id) return result } @@ -159,6 +166,10 @@ class RecipeRepositoryImpl( db.delete(id) entity } + entity + ?.imageUrl + ?.takeIf { it.isNotBlank() } + ?.let { imageUrl -> scope.launch { photoStorage.deletePhoto(imageUrl) } } entity?.remoteId?.let { remoteId -> scope.launch { try { diff --git a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt index 72aa31ed..9bc6fb61 100644 --- a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt +++ b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt @@ -2,6 +2,7 @@ package com.plusmobileapps.chefmate.recipe.data.impl.remote +import co.touchlab.kermit.Logger import com.plusmobileapps.chefmate.auth.data.AuthState import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository import com.plusmobileapps.chefmate.di.AppScope @@ -33,6 +34,20 @@ class SupabaseRecipePhotoStorage( return bucket.publicUrl(path) } + override suspend fun deletePhoto(publicUrl: String) { + val needle = "/$BUCKET_NAME/" + if (!publicUrl.contains(needle)) return + val path = publicUrl.substringAfter(needle) + if (path.isBlank()) return + try { + supabaseClient.storage.from(BUCKET_NAME).delete(path) + } catch (t: Throwable) { + Logger.w(throwable = t, tag = "SupabaseRecipePhotoStorage") { + "Failed to delete photo at $path" + } + } + } + private companion object { const val BUCKET_NAME = "recipe-photos" const val ANONYMOUS_FOLDER = "anonymous" diff --git a/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt b/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt index 59670ad6..8c4e54cc 100644 --- a/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt +++ b/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt @@ -2,4 +2,10 @@ package com.plusmobileapps.chefmate.recipe.data interface RecipePhotoStorage { suspend fun uploadPhoto(bytes: ByteArray, fileExtension: String): String + + /** + * Best-effort delete of a previously uploaded photo. URLs that don't point at the recipe-photos + * bucket are ignored; failures are swallowed so caller flows aren't disrupted. + */ + suspend fun deletePhoto(publicUrl: String) } diff --git a/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt b/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt index 5badab23..2766d381 100644 --- a/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt +++ b/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt @@ -5,12 +5,17 @@ import com.plusmobileapps.chefmate.recipe.data.RecipePhotoStorage class FakeRecipePhotoStorage(var nextResult: () -> String = { "https://example.com/photo.jpg" }) : RecipePhotoStorage { val uploads = mutableListOf() + val deletes = mutableListOf() override suspend fun uploadPhoto(bytes: ByteArray, fileExtension: String): String { uploads.add(Upload(bytes = bytes, fileExtension = fileExtension)) return nextResult() } + override suspend fun deletePhoto(publicUrl: String) { + deletes.add(publicUrl) + } + data class Upload(val bytes: ByteArray, val fileExtension: String) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/docs/supabase-storage-setup.sql b/docs/supabase-storage-setup.sql index bc146896..1dbed371 100644 --- a/docs/supabase-storage-setup.sql +++ b/docs/supabase-storage-setup.sql @@ -94,3 +94,34 @@ drop trigger if exists recipes_delete_photo on public.recipes; create trigger recipes_delete_photo after delete on public.recipes for each row execute function public.delete_recipe_photo(); + +-- 4a. When a recipe's image_url is replaced, delete the previous photo from storage. +-- Mirrors the delete trigger so admin updates / other clients also clean up. The +-- client-side photo storage layer also calls deletePhoto() during updateRecipe, so +-- this is belt-and-suspenders for direct DB edits. +create or replace function public.delete_replaced_recipe_photo() +returns trigger +language plpgsql +security definer +set search_path = public, storage +as $$ +declare + storage_path text; +begin + if old.image_url is not null and old.image_url like '%/recipe-photos/%' then + storage_path := substring(old.image_url from '/recipe-photos/(.+)$'); + if storage_path is not null and length(storage_path) > 0 then + delete from storage.objects + where bucket_id = 'recipe-photos' and name = storage_path; + end if; + end if; + return new; +end; +$$; + +drop trigger if exists recipes_update_photo on public.recipes; +create trigger recipes_update_photo +after update of image_url on public.recipes +for each row +when (old.image_url is distinct from new.image_url) +execute function public.delete_replaced_recipe_photo(); From 194e9c5b4fb1aaacc960055403a9e018afa86e54 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Mon, 18 May 2026 16:02:09 -0700 Subject: [PATCH 8/9] fix(recipe): await remote push so save survives sign-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createRecipe and updateRecipe now await pushAddToRemote/ pushUpdateToRemote instead of firing them on the repo scope and returning. Previously, save() resolved before the recipes-table upsert reached Supabase, so signing out (which calls clearLocalData()) destroyed the still-dirty row before the network call landed — on sign-in, the pull replayed Supabase's stale image_url and the new photo "reverted." Offline behavior is unchanged: a thrown upsert still leaves isDirty=1 for the next sync. Also adds the missing photoStorage parameter to RecipeRepositoryImplTest that the rebase didn't pick up. Co-Authored-By: Claude Opus 4.7 --- .../recipe/data/impl/RecipeRepositoryImpl.kt | 121 +++++++++--------- .../data/impl/RecipeRepositoryImplTest.kt | 2 + 2 files changed, 61 insertions(+), 62 deletions(-) diff --git a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt index fa5b2393..686b33c3 100644 --- a/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt +++ b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt @@ -190,67 +190,22 @@ class RecipeRepositoryImpl( } } - private fun pushAddToRemote(localId: Long) { + private suspend fun pushAddToRemote(localId: Long) { val authState = authRepository.state.value if (authState !is AuthState.Authenticated) return - scope.launch { - try { - val entity = db.getById(localId).executeAsOneOrNull() ?: return@launch - val clientId = - entity.clientId - ?: Uuid.random().toString().also { newId -> - db.updateClientId(clientId = newId, id = localId) - } - syncingIds.update { it + localId } - try { - val remoteRecipe = - remoteDataSource.upsertRecipe( - RemoteRecipe( - ownerId = authState.user.userId, - title = entity.title, - description = entity.description, - ingredients = entity.ingredients, - directions = entity.directions, - imageUrl = entity.imageUrl, - sourceUrl = entity.sourceUrl, - servings = entity.servings?.toInt(), - prepTime = entity.prepTime?.toInt(), - cookTime = entity.cookTime?.toInt(), - totalTime = entity.totalTime?.toInt(), - calories = entity.calories?.toInt(), - starRating = entity.starRating?.toInt(), - isFavorite = entity.isFavorite, - createdAt = entity.createdAt, - updatedAt = entity.updatedAt, - clientId = clientId, - ) - ) - db.updateRemoteId(remoteId = remoteRecipe.id, id = localId) - remoteRecipe.id?.let { recipeRemoteId -> - remoteDataSource.setRecipeCategories( - recipeRemoteId, - attachedCategoryRemoteIds(localId), - ) + try { + val entity = + withContext(ioContext) { db.getById(localId).executeAsOneOrNull() } ?: return + val clientId = + entity.clientId + ?: Uuid.random().toString().also { newId -> + withContext(ioContext) { db.updateClientId(clientId = newId, id = localId) } } - } finally { - syncingIds.update { it - localId } - } - } catch (_: Exception) {} - } - } - - private fun pushUpdateToRemote(localId: Long) { - val authState = authRepository.state.value - if (authState !is AuthState.Authenticated) return - scope.launch { + syncingIds.update { it + localId } try { - val entity = db.getById(localId).executeAsOneOrNull() ?: return@launch - val remoteId = entity.remoteId ?: return@launch - syncingIds.update { it + localId } - try { + val remoteRecipe = remoteDataSource.upsertRecipe( RemoteRecipe( - id = remoteId, ownerId = authState.user.userId, title = entity.title, description = entity.description, @@ -265,20 +220,62 @@ class RecipeRepositoryImpl( calories = entity.calories?.toInt(), starRating = entity.starRating?.toInt(), isFavorite = entity.isFavorite, + createdAt = entity.createdAt, updatedAt = entity.updatedAt, - clientId = entity.clientId, + clientId = clientId, ) ) + withContext(ioContext) { + db.updateRemoteId(remoteId = remoteRecipe.id, id = localId) + } + remoteRecipe.id?.let { recipeRemoteId -> remoteDataSource.setRecipeCategories( - remoteId, + recipeRemoteId, attachedCategoryRemoteIds(localId), ) - db.clearDirty(localId) - } finally { - syncingIds.update { it - localId } } - } catch (_: Exception) {} - } + } finally { + syncingIds.update { it - localId } + } + } catch (_: Exception) {} + } + + private suspend fun pushUpdateToRemote(localId: Long) { + val authState = authRepository.state.value + if (authState !is AuthState.Authenticated) return + try { + val entity = + withContext(ioContext) { db.getById(localId).executeAsOneOrNull() } ?: return + val remoteId = entity.remoteId ?: return + syncingIds.update { it + localId } + try { + remoteDataSource.upsertRecipe( + RemoteRecipe( + id = remoteId, + ownerId = authState.user.userId, + title = entity.title, + description = entity.description, + ingredients = entity.ingredients, + directions = entity.directions, + imageUrl = entity.imageUrl, + sourceUrl = entity.sourceUrl, + servings = entity.servings?.toInt(), + prepTime = entity.prepTime?.toInt(), + cookTime = entity.cookTime?.toInt(), + totalTime = entity.totalTime?.toInt(), + calories = entity.calories?.toInt(), + starRating = entity.starRating?.toInt(), + isFavorite = entity.isFavorite, + updatedAt = entity.updatedAt, + clientId = entity.clientId, + ) + ) + remoteDataSource.setRecipeCategories(remoteId, attachedCategoryRemoteIds(localId)) + withContext(ioContext) { db.clearDirty(localId) } + } finally { + syncingIds.update { it - localId } + } + } catch (_: Exception) {} } private suspend fun syncWithRemote(userId: String) = syncMutex.withLock { diff --git a/client/recipe/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImplTest.kt b/client/recipe/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImplTest.kt index 67b3dc70..39c80de2 100644 --- a/client/recipe/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImplTest.kt +++ b/client/recipe/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImplTest.kt @@ -15,6 +15,7 @@ import com.plusmobileapps.chefmate.recipe.data.impl.remote.CategoryRemoteDataSou import com.plusmobileapps.chefmate.recipe.data.impl.remote.RecipeRemoteDataSource import com.plusmobileapps.chefmate.recipe.data.impl.remote.RemoteCategory import com.plusmobileapps.chefmate.recipe.data.impl.remote.RemoteRecipe +import com.plusmobileapps.chefmate.recipe.data.testing.FakeRecipePhotoStorage import com.plusmobileapps.chefmate.util.testing.FakeDateTimeUtil import io.kotest.matchers.shouldBe import kotlin.test.Test @@ -43,6 +44,7 @@ class RecipeRepositoryImplTest { dateTimeUtil = dateTimeUtil, remoteDataSource = recipeRemote, authRepository = fakeAuth, + photoStorage = FakeRecipePhotoStorage(), ) private val categoryRepository = From e6a302ef19393d9e3d7ab5f7298007cd9c69574f Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Mon, 18 May 2026 16:04:08 -0700 Subject: [PATCH 9/9] build: replace local.properties for bugsnag and supabase to just pull from gradle properties --- client/shared/build.gradle.kts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/shared/build.gradle.kts b/client/shared/build.gradle.kts index a75855bc..06d713ab 100644 --- a/client/shared/build.gradle.kts +++ b/client/shared/build.gradle.kts @@ -35,29 +35,29 @@ val localProperties = } } -// Read Supabase credentials from local.properties or environment variables +// Read Supabase credentials from Gradle properties (e.g. ~/.gradle/gradle.properties) or env vars val supabaseUrl = - localProperties.getProperty("supabase.url") + (findProperty("supabase.url") as? String) ?: System.getenv("SUPABASE_URL") ?: "https://your-project-id.supabase.co" val supabaseKey = - localProperties.getProperty("supabase.key") + (findProperty("supabase.key") as? String) ?: System.getenv("SUPABASE_KEY") ?: "your-anon-public-key" val supabaseTestingUrl = - localProperties.getProperty("supabase.testing.url") + (findProperty("supabase.testing.url") as? String) ?: System.getenv("SUPABASE_TESTING_URL") ?: supabaseUrl val supabaseTestingKey = - localProperties.getProperty("supabase.testing.key") + (findProperty("supabase.testing.key") as? String) ?: System.getenv("SUPABASE_TESTING_KEY") ?: supabaseKey val bugsnagApiKey = - localProperties.getProperty("bugsnag.apiKey") ?: System.getenv("BUGSNAG_API_KEY") ?: "" + (findProperty("bugsnag.apiKey") as? String) ?: System.getenv("BUGSNAG_API_KEY") ?: "" // Collect test users by incrementing n until a pair is missing. Looked up in order: // 1. local.properties at the project root (chefmate.user. / chefmate.user.password.)