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..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 @@ -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 @@ -50,6 +51,8 @@ class EditRecipeBlocImpl( isLoading = it.isLoading, isSaving = it.isSaving, showDiscardChangesDialog = it.showDiscardChangesDialog, + uploadError = + it.uploadError?.let { ResourceString(Res.string.edit_recipe_upload_failed) }, ) } override val title: StateFlow = viewModel.title @@ -67,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 { @@ -172,6 +176,14 @@ class EditRecipeBlocImpl( viewModel.save() } + override fun onPhotoPicked(bytes: ByteArray, fileExtension: String) { + viewModel.setPendingPhoto(bytes = bytes, fileExtension = fileExtension) + } + + override fun onUploadErrorDismissed() { + viewModel.dismissUploadError() + } + override fun onBackClicked() { viewModel.tryToClose() } 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..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 @@ -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() @@ -91,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 -> { @@ -250,11 +257,39 @@ class EditRecipeViewModel( _state.update { it.copy(showDiscardChangesDialog = false) } } + fun setPendingPhoto(bytes: ByteArray, fileExtension: String) { + _pendingPhotoBytes.value = bytes + pendingPhotoExtension = fileExtension + _state.update { it.copy(uploadError = null) } + } + + fun dismissUploadError() { + _state.update { it.copy(uploadError = null) } + } + fun save() { - val originalRecipe = _state.value.recipe - val currentRecipe = currentRecipe() - _state.update { it.copy(isLoading = true) } + if (_state.value.isSaving) return + _state.update { it.copy(isSaving = true, uploadError = null) } scope.launch { + val pendingBytes = _pendingPhotoBytes.value + val pendingExt = pendingPhotoExtension + if (pendingBytes != null && pendingExt != null) { + try { + val url = + photoStorage.uploadPhoto(bytes = pendingBytes, fileExtension = pendingExt) + _imageUrl.value = url + _pendingPhotoBytes.value = null + pendingPhotoExtension = null + } catch (t: Throwable) { + Logger.e(throwable = t, tag = "EditRecipeViewModel") { + "Failed to upload photo" + } + _state.update { it.copy(isSaving = false, uploadError = t) } + return@launch + } + } + val originalRecipe = _state.value.recipe + val currentRecipe = currentRecipe() val savedRecipe = if (originalRecipe != null) { repository.updateRecipe(currentRecipe) @@ -295,8 +330,9 @@ class EditRecipeViewModel( private fun shouldShowDiscardChangesDialog( originalRecipe: Recipe?, currentRecipe: Recipe, - ): Boolean = - when { + ): Boolean { + if (_pendingPhotoBytes.value != null) return true + return when { originalRecipe != null -> originalRecipe.isDirty() else -> currentRecipe.title.isNotBlank() || @@ -313,6 +349,7 @@ class EditRecipeViewModel( currentRecipe.starRating != null || currentRecipe.categories.isNotEmpty() } + } private fun Recipe.isDirty(): Boolean = title != _title.value || @@ -355,6 +392,7 @@ class EditRecipeViewModel( val isSaving: Boolean = false, val showDiscardChangesDialog: Boolean = false, val recipe: Recipe? = null, + val uploadError: Throwable? = null, ) sealed class Output { 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..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 @@ -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,64 @@ class EditRecipeViewModelTest { recipes.value.first().categories shouldBe emptySet() } + @Test + fun When_photo_picked_Then_bytes_are_held_and_no_upload_happens() { + val vm = createViewModel() + + vm.setPendingPhoto(bytes = byteArrayOf(1, 2, 3), fileExtension = "jpg") + + vm.pendingPhotoBytes.value?.toList() shouldBe listOf(1, 2, 3) + vm.imageUrl.value shouldBe "" + photoStorage.uploads.size shouldBe 0 + } + + @Test + fun When_save_with_pending_photo_Then_photo_is_uploaded_before_recipe_save() = runTest { + photoStorage.nextResult = { "https://cdn.example.com/photo.jpg" } + val vm = createViewModel() + vm.updateTitle("With photo") + vm.setPendingPhoto(bytes = byteArrayOf(7, 7, 7), fileExtension = "png") + + vm.save() + val output = vm.output.first() + + output.shouldBeFinished() + photoStorage.uploads.size shouldBe 1 + photoStorage.uploads.first().fileExtension shouldBe "png" + recipes.value.single().imageUrl shouldBe "https://cdn.example.com/photo.jpg" + vm.pendingPhotoBytes.value shouldBe null + vm.state.value.uploadError shouldBe null + } + + @Test + fun When_save_upload_fails_Then_recipe_is_not_saved_and_error_is_surfaced() = runTest { + val failure = RuntimeException("network down") + photoStorage.nextResult = { throw failure } + val vm = createViewModel() + vm.updateTitle("Will not save") + vm.setPendingPhoto(bytes = byteArrayOf(0), fileExtension = "jpg") + + vm.save() + + recipes.value shouldBe emptyList() + vm.state.value.isSaving shouldBe false + vm.state.value.uploadError shouldBe failure + vm.pendingPhotoBytes.value?.toList() shouldBe listOf(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/composeResources/values/strings.xml b/client/recipe/core/public/src/commonMain/composeResources/values/strings.xml index 8b83a112..3c79c602 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,11 @@ Enter recipe description Image URL 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..d3f9739b --- /dev/null +++ b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/CropPhotoOverlay.kt @@ -0,0 +1,190 @@ +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 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 +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) } + + Dialog( + onDismissRequest = { if (!isProcessing) onCancel() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + 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/EditRecipeBloc.kt b/client/recipe/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/core/edit/EditRecipeBloc.kt index 11f40a43..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) @@ -102,11 +104,16 @@ 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 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..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 @@ -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 @@ -31,10 +34,15 @@ 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 import chefmate.client.recipe.core.public.generated.resources.Res @@ -78,14 +86,23 @@ 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 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.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 @@ -101,6 +118,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 +173,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 +348,101 @@ 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) { + scope.launch { + val bitmap = + runCatching { + withContext(Dispatchers.Default) { decodeImageBitmap(picked.bytes) } + } + .getOrNull() + if (bitmap != null) { + pendingCrop = PendingCrop(bytes = picked.bytes, bitmap = bitmap) + } + } + } + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + ) { + val previewModel: Any? = pendingBytes ?: imageUrl.takeIf { it.isNotBlank() } + if (previewModel != null) { + AsyncImage( + model = previewModel, + contentDescription = null, + modifier = + Modifier.fillMaxWidth().aspectRatio(1f).clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop, + ) + } + 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)) + } + } + + 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 +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() @@ -540,6 +657,7 @@ Salt for pasta water""" override val availableUserCategories: StateFlow> = MutableStateFlow(emptyList()) + override val pendingPhotoBytes: StateFlow = MutableStateFlow(null) override fun onTitleChanged(title: String) {} @@ -587,6 +705,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/RecipeRepositoryImpl.kt b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/RecipeRepositoryImpl.kt index 4eb07dc2..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 @@ -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 { @@ -179,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, @@ -254,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/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..9bc6fb61 --- /dev/null +++ b/client/recipe/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/impl/remote/SupabaseRecipePhotoStorage.kt @@ -0,0 +1,55 @@ +@file:OptIn(ExperimentalUuidApi::class) + +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 +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 ownerFolder = + (authRepository.state.value as? AuthState.Authenticated)?.user?.userId + ?: ANONYMOUS_FOLDER + val bucket = supabaseClient.storage.from(BUCKET_NAME) + val sanitizedExtension = fileExtension.trimStart('.').lowercase().ifBlank { "jpg" } + val path = "$ownerFolder/${Uuid.random()}.$sanitizedExtension" + bucket.upload(path = path, data = bytes) { upsert = false } + 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/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 = 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..8c4e54cc --- /dev/null +++ b/client/recipe/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/RecipePhotoStorage.kt @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..2766d381 --- /dev/null +++ b/client/recipe/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/data/testing/FakeRecipePhotoStorage.kt @@ -0,0 +1,30 @@ +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() + 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 + 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/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.) 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/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/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/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/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/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/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/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 new file mode 100644 index 00000000..1dbed371 --- /dev/null +++ b/docs/supabase-storage-setup.sql @@ -0,0 +1,127 @@ +-- 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 +); + +-- 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 +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 +); + +-- 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(); + +-- 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(); 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]