From 14b39736c41927673f96df1e14ccd3da298c43dc Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz <31364841+plusmobileapps@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:29:01 -0700 Subject: [PATCH 1/9] feat(grocery): rework how grocery items are added and deleted (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(grocery): drop the in-field add button from the grocery input Adding an item is now done with the keyboard's send action on mobile or Enter on desktop, so the trailing "+" icon button inside the text field was redundant chrome. Removing it also frees the trailing slot and the now-unused grocery_add_item string. Snapshot references for the grocery list screen re-recorded. Co-Authored-By: Claude Opus 5 * feat(grocery): swipe a grocery row away to delete it on mobile Swipe towards the trailing edge to reveal a red delete background and remove the item. Only end-to-start is enabled so the gesture doesn't fight the Android system back gesture at the leading edge, and the row picks up an opaque surface background so the red stays hidden until the row moves. The trailing delete button stays exactly as it was — the swipe is an additional way to delete, not a replacement. It's off by default on desktop, where a stray mouse drag across a row would be an easy way to lose an item; `swipeToDeleteEnabled` makes that overridable so the gesture can be tested on every target. Co-Authored-By: Claude Opus 5 * feat(grocery): Done adds whatever is left in the grocery input Done previously only dismissed the keyboard, so text the user had typed but not submitted just sat in the field. It now submits that item first and then dismisses, matching what "Done" implies. An empty (or whitespace-only) field still just dismisses, as before. Covered by a robot UI test that types an item, taps Done, and asserts the row lands in the list and the input clears. Co-Authored-By: Claude Opus 5 * feat(grocery): hide the row delete button where swipe-to-delete exists On touch platforms the delete button sits right under a thumb on every row, which makes an accidental tap easy — and now that a row can be swiped away there's a deliberate gesture to reach for instead. Desktop, where swipe is off, keeps the button as the only way to delete a single item. The sync icon becomes the last thing in the row when the button is hidden, and it carries none of the padding an IconButton builds in, so it takes its own inset off the trailing edge. Snapshot references re-recorded (they render as Android, so the button is hidden there). Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../tests/GroceryListDoneButtonUiTest.kt | 23 +++++ .../tests/GroceryListSwipeToDeleteUiTest.kt | 76 +++++++++++++++ .../grocery/core/robots/GroceryListRobot.kt | 28 ++++++ .../composeResources/values/strings.xml | 1 - .../grocery/core/list/GroceryGroupedList.kt | 85 +++++++++++++++++ .../grocery/core/list/GroceryListScreen.kt | 95 ++++++++++++------- .../grocery/core/list/GroceryListTestTags.kt | 1 + ...honePortraitLightScreenshot_73588fdf_0.png | 4 +- ...honePortraitLightScreenshot_73588fdf_0.png | 4 +- ...oneLandscapeLightScreenshot_5e5a0bd2_0.png | 4 +- ...PhonePortraitDarkScreenshot_805fa67c_0.png | 4 +- ...honePortraitLightScreenshot_73588fdf_0.png | 4 +- ...oneLandscapeLightScreenshot_5e5a0bd2_0.png | 4 +- ...PhonePortraitDarkScreenshot_805fa67c_0.png | 4 +- ...honePortraitLightScreenshot_73588fdf_0.png | 4 +- ...PhonePortraitDarkScreenshot_805fa67c_0.png | 4 +- ...honePortraitLightScreenshot_73588fdf_0.png | 4 +- 17 files changed, 293 insertions(+), 56 deletions(-) create mode 100644 client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListDoneButtonUiTest.kt create mode 100644 client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListSwipeToDeleteUiTest.kt diff --git a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListDoneButtonUiTest.kt b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListDoneButtonUiTest.kt new file mode 100644 index 000000000..36c6541f8 --- /dev/null +++ b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListDoneButtonUiTest.kt @@ -0,0 +1,23 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.plusmobileapps.chefmate.tests + +import androidx.compose.ui.test.ExperimentalTestApi +import com.plusmobileapps.chefmate.grocery.core.robots.groceryList +import com.plusmobileapps.chefmate.harness.runRootBlocTest +import com.plusmobileapps.chefmate.recipe.bottomnav.robots.bottomNav +import kotlin.test.Test + +class GroceryListDoneButtonUiTest { + + @Test + fun done_adds_whatever_is_left_in_the_input() = runRootBlocTest { + bottomNav().clickGroceriesTab() + + groceryList() + .enterItemNamePrefix("Strawberries") + .clickDone() + .awaitItemDisplayed("Strawberries") + .assertItemInputEmpty() + } +} diff --git a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListSwipeToDeleteUiTest.kt b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListSwipeToDeleteUiTest.kt new file mode 100644 index 000000000..462402a98 --- /dev/null +++ b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/GroceryListSwipeToDeleteUiTest.kt @@ -0,0 +1,76 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.plusmobileapps.chefmate.tests + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.waitUntilDoesNotExist +import com.plusmobileapps.chefmate.grocery.core.list.GroceryDisplayGroup +import com.plusmobileapps.chefmate.grocery.core.list.GroceryDisplayItem +import com.plusmobileapps.chefmate.grocery.core.list.GroceryGroupedList +import com.plusmobileapps.chefmate.grocery.data.GroceryCategory +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Covers the swipe-to-delete gesture on the shared grocery list rows. `swipeToDeleteEnabled` is + * passed explicitly rather than left to its platform default so the gesture is exercised on every + * target the test suite runs on, not just touch platforms. + */ +class GroceryListSwipeToDeleteUiTest { + + @Test + fun swiping_a_row_away_deletes_that_item() = runComposeUiTest { + val deleted = mutableStateListOf() + setGroceryListContent(deleted, swipeToDeleteEnabled = true) + + onNodeWithText("Apples").performTouchInput { swipeLeft() } + + waitUntilDoesNotExist(hasText("Apples")) + assertEquals(listOf("Apples"), deleted.map { it.displayName }) + } + + @Test + fun swiping_does_nothing_when_swipe_to_delete_is_disabled() = runComposeUiTest { + val deleted = mutableStateListOf() + setGroceryListContent(deleted, swipeToDeleteEnabled = false) + + onNodeWithText("Apples").performTouchInput { swipeLeft() } + waitForIdle() + + onNodeWithText("Apples").assertIsDisplayed() + assertTrue(deleted.isEmpty()) + } +} + +private fun ComposeUiTest.setGroceryListContent( + deleted: MutableList, + swipeToDeleteEnabled: Boolean, +) { + setContent { + val items = + listOf( + GroceryDisplayItem(key = 1L, displayName = "Apples"), + GroceryDisplayItem(key = 2L, displayName = "Bananas"), + ) + .filterNot { it in deleted } + ChefMateTheme { + GroceryGroupedList( + groups = listOf(GroceryDisplayGroup(GroceryCategory.PRODUCE, items)), + onItemClick = {}, + onCheckedChange = {}, + onSwipeToDelete = { deleted += it }, + swipeToDeleteEnabled = swipeToDeleteEnabled, + ) + } + } +} diff --git a/client/grocery/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/robots/GroceryListRobot.kt b/client/grocery/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/robots/GroceryListRobot.kt index edee50b69..61c95f29b 100644 --- a/client/grocery/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/robots/GroceryListRobot.kt +++ b/client/grocery/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/robots/GroceryListRobot.kt @@ -2,8 +2,11 @@ package com.plusmobileapps.chefmate.grocery.core.robots +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasAnyAncestor @@ -15,6 +18,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.waitUntilAtLeastOneExists import androidx.compose.ui.test.waitUntilExactlyOneExists +import androidx.compose.ui.text.AnnotatedString import com.plusmobileapps.chefmate.grocery.core.detail.GroceryDetailTestTags import com.plusmobileapps.chefmate.grocery.core.list.GroceryListTestTags @@ -64,6 +68,30 @@ class GroceryListRobot(private val test: ComposeUiTest) { test.onNode(hasTestTag(GroceryListTestTags.ITEM_INPUT) and onScreen).assertTextEquals(text) } + /** + * Asserts the input has been cleared. Checked against `EditableText` rather than + * [assertItemInputText] because an empty field renders its placeholder, and the placeholder + * counts towards the node's `Text`. + */ + fun assertItemInputEmpty(): GroceryListRobot = apply { + test + .onNode(hasTestTag(GroceryListTestTags.ITEM_INPUT) and onScreen) + .assert( + SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("")) + ) + } + + /** Taps the Done button beside the input, which only exists while the field has focus. */ + fun clickDone(): GroceryListRobot = apply { + val matcher = hasTestTag(GroceryListTestTags.DONE_BUTTON) and onScreen + test.waitUntilExactlyOneExists(matcher) + test.onNode(matcher).performClick() + } + + fun awaitItemDisplayed(displayName: String): GroceryListRobot = apply { + test.waitUntilAtLeastOneExists(hasText(displayName) and onScreen) + } + /** Opens the list selector (bottom sheet on phones, dropdown on tablets). */ fun openListSelector(): GroceryListRobot = apply { test diff --git a/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml b/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml index 41df4a004..fea91f2b0 100644 --- a/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml +++ b/client/grocery/core/public/src/commonMain/composeResources/values/strings.xml @@ -5,7 +5,6 @@ Quantity Aisle Purchased - Add Item Checked Not Checked Delete Item diff --git a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryGroupedList.kt b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryGroupedList.kt index f57bde6c4..1e509b3a0 100644 --- a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryGroupedList.kt +++ b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryGroupedList.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -17,12 +18,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckBox import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -31,13 +35,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chefmate.client.grocery.core.public.generated.resources.Res import chefmate.client.grocery.core.public.generated.resources.grocery_checked +import chefmate.client.grocery.core.public.generated.resources.grocery_delete_item import chefmate.client.grocery.core.public.generated.resources.grocery_not_checked import chefmate.client.grocery.core.public.generated.resources.grocery_recipe_source +import com.plusmobileapps.chefmate.Platform +import com.plusmobileapps.chefmate.currentPlatform import com.plusmobileapps.chefmate.grocery.core.displayName import com.plusmobileapps.chefmate.grocery.data.GroceryCategory import com.plusmobileapps.chefmate.text.FixedString import com.plusmobileapps.chefmate.text.PhraseModel import com.plusmobileapps.chefmate.ui.text.toInlineMarkdownAnnotatedString +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme import org.jetbrains.compose.resources.stringResource data class GroceryDisplayItem( @@ -53,6 +61,14 @@ data class GroceryDisplayGroup(val category: GroceryCategory, val items: List Unit)? = null, + onSwipeToDelete: ((GroceryDisplayItem) -> Unit)? = null, + swipeToDeleteEnabled: Boolean = supportsSwipeToDelete, footer: (LazyListScope.() -> Unit)? = null, ) { LazyColumn(state = state, modifier = modifier.fillMaxSize()) { @@ -81,6 +99,10 @@ fun GroceryGroupedList( onClick = { onItemClick(item.key) }, trailingContent = trailingContent, highlighted = item.key == highlightedKey, + onSwipeToDelete = + onSwipeToDelete + ?.takeIf { swipeToDeleteEnabled } + ?.let { delete -> { delete(item) } }, modifier = Modifier.animateItem(), ) HorizontalDivider() @@ -110,6 +132,69 @@ private fun GroceryDisplayListItem( trailingContent: (@Composable (GroceryDisplayItem) -> Unit)?, modifier: Modifier = Modifier, highlighted: Boolean = false, + onSwipeToDelete: (() -> Unit)? = null, +) { + if (onSwipeToDelete != null) { + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + state = dismissState, + modifier = modifier, + // Start-to-end is left alone so the swipe doesn't fight the Android system back + // gesture, which starts at the leading edge. Delete is a swipe towards the trailing + // edge only. + enableDismissFromStartToEnd = false, + onDismiss = { onSwipeToDelete() }, + backgroundContent = { DeleteSwipeBackground() }, + ) { + GroceryItemRow( + item = item, + onCheckedChange = onCheckedChange, + onClick = onClick, + trailingContent = trailingContent, + highlighted = highlighted, + // The row is normally transparent over the screen background; it needs to be + // opaque here so the red delete background stays hidden until it's swiped aside. + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) + } + } else { + GroceryItemRow( + item = item, + onCheckedChange = onCheckedChange, + onClick = onClick, + trailingContent = trailingContent, + highlighted = highlighted, + modifier = modifier, + ) + } +} + +/** Revealed behind a grocery row as it's swiped away, signalling what the gesture will do. */ +@Composable +private fun DeleteSwipeBackground() { + Box( + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = ChefMateTheme.dimens.paddingNormal), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(Res.string.grocery_delete_item), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + +@Composable +private fun GroceryItemRow( + item: GroceryDisplayItem, + onCheckedChange: () -> Unit, + onClick: () -> Unit, + trailingContent: (@Composable (GroceryDisplayItem) -> Unit)?, + modifier: Modifier = Modifier, + highlighted: Boolean = false, ) { // Briefly tint the row when it's the freshly added item, then fade back to transparent so the // user can see where it landed in the list. diff --git a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListScreen.kt b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListScreen.kt index 5f9d2e8e4..d5c3e2de3 100644 --- a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListScreen.kt +++ b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListScreen.kt @@ -90,7 +90,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chefmate.client.grocery.core.public.generated.resources.Res import chefmate.client.grocery.core.public.generated.resources.grocery_accept -import chefmate.client.grocery.core.public.generated.resources.grocery_add_item import chefmate.client.grocery.core.public.generated.resources.grocery_add_item_hint import chefmate.client.grocery.core.public.generated.resources.grocery_apply import chefmate.client.grocery.core.public.generated.resources.grocery_cancel @@ -376,9 +375,17 @@ fun GroceryListScreen( GroceryItemTrailingContent( item = item, onDeleteClick = { bloc.onGroceryItemDelete(item) }, + // Where the row can be swiped away, the always-visible + // delete button sits right under a thumb and is too easy + // to hit by accident; the swipe is the deliberate gesture. + showDeleteButton = !supportsSwipeToDelete, ) } }, + swipeToDeleteEnabled = supportsSwipeToDelete, + onSwipeToDelete = { displayItem -> + itemLookup[displayItem.key as Long]?.let(bloc::onGroceryItemDelete) + }, ) } } @@ -908,7 +915,15 @@ private fun GroceryListInput( enter = fadeIn() + expandHorizontally(), exit = fadeOut() + shrinkHorizontally(), ) { - TextButton(onClick = dismissKeyboard) { + // Done finishes what the user was typing: anything left in the field is added + // before the keyboard goes away, so half-typed text isn't silently abandoned. + TextButton( + onClick = { + if (trimmedQuery.isNotEmpty()) onAddClick() + dismissKeyboard() + }, + modifier = Modifier.testTag(GroceryListTestTags.DONE_BUTTON), + ) { Text(stringResource(Res.string.grocery_done)) } } @@ -1002,7 +1017,8 @@ private fun GroceryItemNameTextField( capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send, ), - // Submits the item and keeps the keyboard open so the user can keep entering items. On an + // The IME action (or Enter on desktop) is the only way to submit — there is no in-field add + // button. Submitting keeps the keyboard open so the user can keep entering items. On an // empty field the action instead dismisses the keyboard, matching the Done button and // scroll-to-dismiss gestures. keyboardActions = @@ -1011,45 +1027,54 @@ private fun GroceryItemNameTextField( if (fieldValue.text.isNotBlank()) onAddClick() else onDismissKeyboard() } ), - trailingIcon = { - IconButton(onClick = onAddClick, enabled = fieldValue.text.isNotBlank()) { - Icon( - Icons.Default.Add, - contentDescription = stringResource(Res.string.grocery_add_item), - ) - } - }, ) } @Composable -private fun GroceryItemTrailingContent(item: GroceryItem, onDeleteClick: () -> Unit) { +private fun GroceryItemTrailingContent( + item: GroceryItem, + onDeleteClick: () -> Unit, + showDeleteButton: Boolean, +) { val syncingDescription = stringResource(Res.string.grocery_sync_syncing) - when (item.syncStatus) { - SyncStatus.NOT_SYNCED -> - Icon( - imageVector = Icons.Outlined.CloudOff, - contentDescription = stringResource(Res.string.grocery_sync_not_synced), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - SyncStatus.SYNCING -> - PlusLoadingIndicator( - modifier = Modifier.size(16.dp), - contentDescription = syncingDescription, - strokeWidth = 2.dp, - ) - SyncStatus.SYNCED -> - Icon( - imageVector = Icons.Outlined.CloudDone, - contentDescription = stringResource(Res.string.grocery_sync_synced), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) + // Without the delete button the sync icon is last in the row, and it carries none of the + // padding an IconButton builds in — so it needs its own inset off the trailing edge. + Box( + modifier = + if (showDeleteButton) { + Modifier + } else { + Modifier.padding(end = ChefMateTheme.dimens.paddingNormal) + } + ) { + when (item.syncStatus) { + SyncStatus.NOT_SYNCED -> + Icon( + imageVector = Icons.Outlined.CloudOff, + contentDescription = stringResource(Res.string.grocery_sync_not_synced), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + SyncStatus.SYNCING -> + PlusLoadingIndicator( + modifier = Modifier.size(16.dp), + contentDescription = syncingDescription, + strokeWidth = 2.dp, + ) + SyncStatus.SYNCED -> + Icon( + imageVector = Icons.Outlined.CloudDone, + contentDescription = stringResource(Res.string.grocery_sync_synced), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } } - IconButton(onClick = onDeleteClick) { - Icon(Icons.Default.Delete, stringResource(Res.string.grocery_delete_item)) + if (showDeleteButton) { + IconButton(onClick = onDeleteClick) { + Icon(Icons.Default.Delete, stringResource(Res.string.grocery_delete_item)) + } } } diff --git a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListTestTags.kt b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListTestTags.kt index 9c2c7a981..9159d2127 100644 --- a/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListTestTags.kt +++ b/client/grocery/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/grocery/core/list/GroceryListTestTags.kt @@ -8,6 +8,7 @@ object GroceryListTestTags { const val BROWSE_RECIPES_BUTTON = "grocery_list_browse_recipes_button" const val LIST_SELECTOR = "grocery_list_selector_title" const val ITEM_INPUT = "grocery_list_item_input" + const val DONE_BUTTON = "grocery_list_done_button" const val ITEM_SUGGESTION = "grocery_list_item_suggestion" const val SAVE_AUTOCOMPLETE = "grocery_list_save_autocomplete" } diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompletePhonePortraitLightScreenshot_73588fdf_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompletePhonePortraitLightScreenshot_73588fdf_0.png index 5099dc1b7..235d59b09 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompletePhonePortraitLightScreenshot_73588fdf_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompletePhonePortraitLightScreenshot_73588fdf_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4027e23b447dd9c7517c5598cd80fcbf9c773d1ae0969421fa3ffe783e94b436 -size 82363 +oid sha256:83c4ee53a9f4c47566aad52afb9e04a6d69f9d2d316355a70be6a23dac7acc04 +size 80916 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompleteSavedPhonePortraitLightScreenshot_73588fdf_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompleteSavedPhonePortraitLightScreenshot_73588fdf_0.png index 1fdf28057..01b31a744 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompleteSavedPhonePortraitLightScreenshot_73588fdf_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListAutocompleteSavedPhonePortraitLightScreenshot_73588fdf_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63641ac068dedfe3aad892f2bcb189d8a7cd2d2bd1aab81f97d4782701edbe70 -size 61687 +oid sha256:493fc09b9e07ae30d3e4bde58ef75437e3f9d392e3d9c4d04988f0d8129628ef +size 60252 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png index b8ce838dd..1eb5d4668 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8cbbc5e123be53138646fd623f418f121a4c9118a3872d683912ec55391568db -size 45624 +oid sha256:d4e2c883fc6ada5a30ba3feb46926f55a44c86935fca8e2b7e4c4fdeb75c04bc +size 45296 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitDarkScreenshot_805fa67c_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitDarkScreenshot_805fa67c_0.png index 60676408e..9bab49231 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitDarkScreenshot_805fa67c_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitDarkScreenshot_805fa67c_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54e3e48e59fba9fe4a16b3d9c1f470fef18615de9079639c8524bf7a6cc7fdf5 -size 56529 +oid sha256:314159d5d2563a54d55d2d448f5b0028f95391ca3630d978e464c9eef38d4654 +size 56251 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitLightScreenshot_73588fdf_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitLightScreenshot_73588fdf_0.png index 142e5b2eb..3df271cdf 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitLightScreenshot_73588fdf_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListEmptyPhonePortraitLightScreenshot_73588fdf_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d95a3d3cbb4e2e7a75b38a664c469f5be414b22d78589862da00ff3ef3f0e5d3 -size 56549 +oid sha256:4a5d8b0fad57b16199fe9f78dacca64df02342562ffbdc68a5da5e9c757d5f98 +size 56219 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png index ab9bd1107..434e7240b 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhoneLandscapeLightScreenshot_5e5a0bd2_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40334bef1c4357b924bf46eb4f618144b17b44d07b4f775f90afbad5982c8f3d -size 49546 +oid sha256:b43b984df15b880fceee0157c7e4f4dbd48a30fb444c90ed7798ee13f85825cd +size 49218 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitDarkScreenshot_805fa67c_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitDarkScreenshot_805fa67c_0.png index 3dccb2e9c..041a0f35d 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitDarkScreenshot_805fa67c_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitDarkScreenshot_805fa67c_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79b763ac5d81bbf8b2e483d1ec101871bf288c7a02e2d55f614014bc32c42e8c -size 69557 +oid sha256:b0d18f658c0b18b2b9041679449a1c86456159b5c11494f53a6ad91af9106a50 +size 69269 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitLightScreenshot_73588fdf_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitLightScreenshot_73588fdf_0.png index 20980ecc2..3d458fbb4 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitLightScreenshot_73588fdf_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListFilteredEmptyPhonePortraitLightScreenshot_73588fdf_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82862e2943424372e703ae20cde056cff79b7f9138c5f9699017a13249ba2828 -size 69470 +oid sha256:177cdb8f7ee42b5ae10face14aac477c7bfc28b26c3d4705c94193fccf3e1564 +size 69125 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitDarkScreenshot_805fa67c_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitDarkScreenshot_805fa67c_0.png index 1a4490010..6a6bfe003 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitDarkScreenshot_805fa67c_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitDarkScreenshot_805fa67c_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:814d4cee2978839bbf592a18da086e4e2d8f2f1a1a0aba328470c54fcc03f2f2 -size 60782 +oid sha256:81f4fe9c270f891555e52c812e6eeb98e1cdd67e212c6d08f39805d75b82f1b8 +size 59229 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitLightScreenshot_73588fdf_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitLightScreenshot_73588fdf_0.png index f1862277c..84583f47a 100644 --- a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitLightScreenshot_73588fdf_0.png +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/GroceryListScreenshotTestKt/GroceryListPhonePortraitLightScreenshot_73588fdf_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e444c3907cb643a07c7ed8de17b510322ec56e5043e8800cf4652638c81e7be -size 60944 +oid sha256:bc9642cf57576bcd3e1ec51eb5c842d665cdbeb1b954e1df7dd21bf6ed2d00e8 +size 59421 From d6eebf334541212a836f3dc0d66788ee59a2ff3d Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:14:41 -0700 Subject: [PATCH 2/9] feat(family): add families schema, RLS, and invite email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A family is a group of accounts that share content across grocery lists, recipe books, and the meal plan. This adds the group and membership model plus its invite flow; the family_id columns that scope the three domains land in later phases. Families sit alongside the existing per-entity sharing rather than replacing it — nothing here touches grocery_list_members or recipe_book_members, so sharing a single list or book outside the family keeps working. Structure mirrors 20260425_add_collaboration.sql. Three things worth review: - The partial unique index on family_members(user_id) WHERE status='accepted' is what enforces one family per user. Accepting a second invite fails at the DB with 23505 rather than silently corrupting current_family_id(). - The helpers are SECURITY DEFINER because the families and family_members policies check each other's tables and would otherwise recurse (42P17) — the same failure 20260610_fix_recipe_rls_recursion.sql was written to fix. - current_family() exists because families_invitees_select deliberately widens SELECT to families you've only been invited to, so a blanket select() would pull an unjoined family into the client's cache. Same over-fetch shape as #487. notify_invite_email() gains a 'family' kind rather than a second function, so all three invite kinds stay on one code path; the edge function now resolves the parent name and owner through one lookup table. Co-Authored-By: Claude Opus 5 --- supabase/functions/send-invite-email/index.ts | 47 ++- supabase/migrations/20260813_add_families.sql | 374 ++++++++++++++++++ 2 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20260813_add_families.sql diff --git a/supabase/functions/send-invite-email/index.ts b/supabase/functions/send-invite-email/index.ts index b045b4ac8..fe253b2c9 100644 --- a/supabase/functions/send-invite-email/index.ts +++ b/supabase/functions/send-invite-email/index.ts @@ -1,18 +1,20 @@ // Supabase Edge Function: send-invite-email // -// Emails a collaboration invitee when they're invited to a shared grocery list or recipe book. -// Invites are plain INSERTs into `grocery_list_members` / `recipe_book_members` with -// status='pending', done directly by every client (iOS/Android/Desktop/Web). A database trigger -// (see supabase/migrations/20260707_invite_email_notification.sql) fires `pg_net` at this function -// on each new pending invite, so the notification is client-agnostic and needs no app-side code. +// Emails a collaboration invitee when they're invited to a shared grocery list, recipe book, or +// family. Invites are plain INSERTs into `grocery_list_members` / `recipe_book_members` / +// `family_members` with status='pending', done directly by every client (iOS/Android/Desktop/Web). +// A database trigger (see supabase/migrations/20260707_invite_email_notification.sql, extended for +// families by 20260813_add_families.sql) fires `pg_net` at this function on each new pending +// invite, so the notification is client-agnostic and needs no app-side code. // // Auth: callers must present `Authorization: Bearer `. Only the DB trigger // should reach this — it must NOT be invokable with an ordinary user JWT. Set INVITE_HOOK_SECRET // to a long random value and store the same value in Vault for the trigger (see the migration). // // Body (sent by the trigger): -// { kind: "grocery" | "recipe_book", memberId, parentId, invitedEmail, invitedBy, role } -// `invitedBy` is only present for grocery invites; recipe-book invites fall back to the book owner. +// { kind: "grocery" | "recipe_book" | "family", memberId, parentId, invitedEmail, invitedBy, role } +// `invitedBy` is present for grocery and family invites; recipe-book invites fall back to the book +// owner. // // Deploy: supabase functions deploy send-invite-email // Secrets: supabase secrets set RESEND_API_KEY= INVITE_HOOK_SECRET= @@ -31,8 +33,10 @@ const corsHeaders = { const DEFAULT_FROM = "Chef Mate "; const DEFAULT_APP_URL = "https://chefmate.plusmobileapps.com"; +type InviteKind = "grocery" | "recipe_book" | "family"; + interface InvitePayload { - kind: "grocery" | "recipe_book"; + kind: InviteKind; memberId: string; parentId: string; invitedEmail: string; @@ -40,6 +44,14 @@ interface InvitePayload { role?: string | null; } +// Per-kind copy and the parent table to resolve the name/owner from. All three parents expose +// `name` and `owner_id`, so one lookup shape covers them. +const KINDS: Record = { + grocery: { table: "grocery_lists", label: "grocery list", fallbackName: "a grocery list" }, + recipe_book: { table: "recipe_books", label: "recipe book", fallbackName: "a recipe book" }, + family: { table: "families", label: "family", fallbackName: "a family" }, +}; + Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); @@ -65,24 +77,31 @@ Deno.serve(async (req) => { return json({ error: "Missing kind, parentId, or invitedEmail" }, 400); } + const kind = KINDS[payload.kind]; + if (!kind) { + return json({ error: `Unknown kind: ${payload.kind}` }, 400); + } + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const admin = createClient(supabaseUrl, serviceRoleKey); - // Resolve the parent list/book: its display name and owner (the inviter fallback). - const table = payload.kind === "grocery" ? "grocery_lists" : "recipe_books"; + // Resolve the parent list/book/family: its display name and owner (the inviter fallback). const { data: parent, error: parentError } = await admin - .from(table) + .from(kind.table) .select("name, owner_id") .eq("id", payload.parentId) .single(); if (parentError || !parent) { - return json({ error: `Could not load ${table}: ${parentError?.message ?? "not found"}` }, 404); + return json( + { error: `Could not load ${kind.table}: ${parentError?.message ?? "not found"}` }, + 404, + ); } - const listName = (parent.name as string) || (payload.kind === "grocery" ? "a grocery list" : "a recipe book"); - const kindLabel = payload.kind === "grocery" ? "grocery list" : "recipe book"; + const listName = (parent.name as string) || kind.fallbackName; + const kindLabel = kind.label; // Inviter: `invited_by` when present (grocery), else the parent owner (recipe books). const inviterId = payload.invitedBy ?? (parent.owner_id as string | null); diff --git a/supabase/migrations/20260813_add_families.sql b/supabase/migrations/20260813_add_families.sql new file mode 100644 index 000000000..e01bef198 --- /dev/null +++ b/supabase/migrations/20260813_add_families.sql @@ -0,0 +1,374 @@ +-- ============================================================ +-- Families (Phase 1) +-- +-- A family is a small group of accounts that share content across grocery lists, recipe books, +-- and the meal plan. This migration adds only the group + membership model and its invite flow; +-- the `family_id` columns that scope the three domains land in later phases. +-- +-- Families sit ALONGSIDE the existing per-entity sharing (`grocery_list_members`, +-- `recipe_book_members`) rather than replacing it — nothing here touches those tables or their +-- policies, so one-off sharing outside the family keeps working. +-- +-- Structure mirrors 20260425_add_collaboration.sql (helpers, triggers, policies, realtime). +-- Run this migration in the Supabase SQL Editor. Safe to re-run. +-- ============================================================ + +-- ============================================================ +-- 1. Tables +-- ============================================================ + +CREATE TABLE IF NOT EXISTS families ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + client_id TEXT UNIQUE, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_families_owner_id ON families(owner_id); + +-- Two roles only, unlike grocery/recipe books' three. A family implies trust: every member can +-- edit all family-scoped content; only the owner invites, removes, renames, and deletes. +CREATE TABLE IF NOT EXISTS family_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + invited_email TEXT NOT NULL, + invited_by UUID REFERENCES auth.users(id), + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_fm_family_id ON family_members(family_id); +CREATE INDEX IF NOT EXISTS idx_fm_user_id ON family_members(user_id); +CREATE INDEX IF NOT EXISTS idx_fm_invited_email ON family_members(invited_email); + +-- One invite per email per family, case-insensitively. A functional index rather than a UNIQUE +-- constraint because the RLS checks compare lower(...) and a plain UNIQUE(family_id, +-- invited_email) would let "A@x.com" and "a@x.com" both in. +CREATE UNIQUE INDEX IF NOT EXISTS idx_fm_family_email_unique + ON family_members (family_id, lower(invited_email)); + +-- THE "exactly one family" RULE. A user may hold any number of *pending* invites, but at most one +-- accepted membership. Accepting a second invite fails here at the DB rather than silently +-- corrupting current_family_id(); the client surfaces "leave your current family first". +CREATE UNIQUE INDEX IF NOT EXISTS idx_fm_one_accepted_family_per_user + ON family_members (user_id) + WHERE status = 'accepted' AND user_id IS NOT NULL; + +-- ============================================================ +-- 2. Triggers +-- ============================================================ + +-- Auto-insert the owner's accepted member row on family creation, so the owner shows up in +-- membership queries without the client having to write a second row. Mirrors +-- auto_add_grocery_list_owner. +CREATE OR REPLACE FUNCTION auto_add_family_owner() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + INSERT INTO family_members (family_id, user_id, role, invited_email, invited_by, status) + SELECT NEW.id, NEW.owner_id, 'owner', + COALESCE((SELECT email FROM auth.users WHERE id = NEW.owner_id), ''), + NEW.owner_id, + 'accepted' + WHERE NEW.owner_id IS NOT NULL; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_auto_add_family_owner ON families; +CREATE TRIGGER trg_auto_add_family_owner + AFTER INSERT ON families + FOR EACH ROW EXECUTE FUNCTION auto_add_family_owner(); + +-- Link email-keyed invites to the account once the invitee signs up. Grocery has the equivalent +-- trigger; recipe books deliberately match by email at read time instead. We take grocery's +-- approach because current_family_id() resolves by user_id and needs the link to exist. +CREATE OR REPLACE FUNCTION migrate_pending_family_invitations() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + UPDATE family_members + SET user_id = NEW.id + WHERE lower(invited_email) = lower(NEW.email) AND user_id IS NULL; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_migrate_pending_family_invitations ON auth.users; +CREATE TRIGGER trg_migrate_pending_family_invitations + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION migrate_pending_family_invitations(); + +-- ============================================================ +-- 3. RLS helper functions +-- +-- SECURITY DEFINER so they bypass RLS and can't recurse — the families policies check membership +-- and the family_members policies check family ownership, so without these they'd reference each +-- other's RLS-protected tables and loop forever (Postgres 42P17). Same failure that +-- 20260610_fix_recipe_rls_recursion.sql was written to fix. +-- ============================================================ + +-- The caller's family, or NULL when they aren't in one. This is the workhorse: every future +-- family_id policy on grocery_lists / recipe_books / meal_plans compares against it. +-- LIMIT 1 is belt-and-braces — idx_fm_one_accepted_family_per_user already guarantees at most one. +CREATE OR REPLACE FUNCTION current_family_id() +RETURNS uuid LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ + SELECT m.family_id + FROM family_members m + WHERE m.user_id = auth.uid() AND m.status = 'accepted' + LIMIT 1; +$$; + +-- The caller's family row, or no rows when they aren't in one. +-- +-- The client MUST use this rather than a blanket `select()` on `families`: the +-- families_invitees_select policy below deliberately widens SELECT to families the caller has only +-- been *invited* to, so an unfiltered select would pull an unjoined family into the local cache. +-- That's the same shape as the over-fetch fixed in 20260725_recipes_sync_excludes_public.sql. +DROP FUNCTION IF EXISTS current_family(); +CREATE OR REPLACE FUNCTION current_family() +RETURNS TABLE(id uuid, name text, owner_id uuid, created_at timestamptz, updated_at timestamptz) +LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ + SELECT f.id, f.name::text, f.owner_id, f.created_at, f.updated_at + FROM families f + WHERE f.id = current_family_id(); +$$; + +CREATE OR REPLACE FUNCTION can_access_family(p_family_id uuid) +RETURNS boolean LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ + SELECT EXISTS ( + SELECT 1 FROM families f + WHERE f.id = p_family_id AND f.owner_id = auth.uid() + ) OR EXISTS ( + SELECT 1 FROM family_members m + WHERE m.family_id = p_family_id + AND m.user_id = auth.uid() + AND m.status = 'accepted' + ); +$$; + +CREATE OR REPLACE FUNCTION is_family_owner(p_family_id uuid) +RETURNS boolean LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ + SELECT EXISTS ( + SELECT 1 FROM families f + WHERE f.id = p_family_id AND f.owner_id = auth.uid() + ); +$$; + +-- Full member list for a family — owner plus every invited member — readable by anyone on the +-- family. SECURITY DEFINER so it can resolve names/avatars from auth.users, which the +-- authenticated role can't read. Clone of recipe_book_collaborators / grocery_list_collaborators; +-- the client reuses the same 7-column shape. +-- DROP first: CREATE OR REPLACE can't change a function's return type. +DROP FUNCTION IF EXISTS family_members_with_profiles(uuid); +CREATE OR REPLACE FUNCTION family_members_with_profiles(p_family_id uuid) +RETURNS TABLE( + member_id uuid, email text, name text, role text, status text, is_owner boolean, avatar_url text +) +LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ + -- Sort outside the UNION: a set-operation ORDER BY can only reference the union's output + -- columns, not expressions over them. + SELECT c.member_id, c.email, c.name, c.role, c.status, c.is_owner, c.avatar_url + FROM ( + SELECT NULL::uuid AS member_id, u.email::text AS email, + (u.raw_user_meta_data ->> 'name')::text AS name, + 'owner'::text AS role, 'accepted'::text AS status, true AS is_owner, + (u.raw_user_meta_data ->> 'avatar_url')::text AS avatar_url + FROM families f + JOIN auth.users u ON u.id = f.owner_id + WHERE f.id = p_family_id AND can_access_family(p_family_id) + UNION ALL + -- Skip the auto-inserted owner row so the owner isn't listed twice; the synthesized row above + -- already covers them. + SELECT m.id, m.invited_email, (mu.raw_user_meta_data ->> 'name')::text, + m.role::text, m.status::text, false, + (mu.raw_user_meta_data ->> 'avatar_url')::text + FROM family_members m + LEFT JOIN auth.users mu ON mu.id = m.user_id + JOIN families f ON f.id = m.family_id + WHERE m.family_id = p_family_id + AND can_access_family(p_family_id) + AND NOT (m.role = 'owner' AND m.user_id = f.owner_id) + ) c + ORDER BY c.is_owner DESC, (c.status = 'accepted') DESC, c.email ASC; +$$; + +-- Pending family invites addressed to the current user, with the family name for the invite card. +-- SECURITY DEFINER avoids an embed across RLS-protected tables. Clone of +-- grocery_list_pending_invites. +DROP FUNCTION IF EXISTS family_pending_invites(); +CREATE OR REPLACE FUNCTION family_pending_invites() +RETURNS TABLE(member_id uuid, family_id uuid, family_name text, role text, status text) +LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ + SELECT m.id, m.family_id, f.name::text, m.role::text, m.status::text + FROM family_members m + JOIN families f ON f.id = m.family_id + WHERE m.status = 'pending' + AND lower(m.invited_email) = lower(current_user_email()) + ORDER BY f.name ASC, m.created_at ASC; +$$; + +-- ============================================================ +-- 4. RLS policies +-- ============================================================ + +ALTER TABLE families ENABLE ROW LEVEL SECURITY; +ALTER TABLE family_members ENABLE ROW LEVEL SECURITY; + +-- === families === +DROP POLICY IF EXISTS "families_select" ON families; +DROP POLICY IF EXISTS "families_invitees_select" ON families; +DROP POLICY IF EXISTS "families_insert" ON families; +DROP POLICY IF EXISTS "families_update" ON families; +DROP POLICY IF EXISTS "families_delete" ON families; + +CREATE POLICY "families_select" ON families FOR SELECT + USING (can_access_family(id)); + +-- Pending invitees need to read the family row to show its name on the invite card, before they +-- have accepted and can_access_family() starts returning true. Mirrors +-- grocery_lists_invitees_select. +CREATE POLICY "families_invitees_select" ON families FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM family_members m + WHERE m.family_id = families.id + AND m.status = 'pending' + AND lower(m.invited_email) = lower(current_user_email()) + ) + ); + +CREATE POLICY "families_insert" ON families FOR INSERT + WITH CHECK (owner_id = auth.uid()); + +CREATE POLICY "families_update" ON families FOR UPDATE + USING (is_family_owner(id)); + +CREATE POLICY "families_delete" ON families FOR DELETE + USING (is_family_owner(id)); + +-- === family_members === +DROP POLICY IF EXISTS "fm_select" ON family_members; +DROP POLICY IF EXISTS "fm_insert" ON family_members; +DROP POLICY IF EXISTS "fm_update" ON family_members; +DROP POLICY IF EXISTS "fm_delete" ON family_members; + +-- See member rows for a family you're on, plus invites addressed to you. +CREATE POLICY "fm_select" ON family_members FOR SELECT USING ( + can_access_family(family_id) + OR user_id = auth.uid() + OR lower(invited_email) = lower(current_user_email()) +); + +-- Only the family owner can invite. +CREATE POLICY "fm_insert" ON family_members FOR INSERT WITH CHECK ( + is_family_owner(family_id) +); + +-- Owner can change roles; the invitee can accept/reject their own invite. +CREATE POLICY "fm_update" ON family_members FOR UPDATE USING ( + is_family_owner(family_id) + OR user_id = auth.uid() + OR lower(invited_email) = lower(current_user_email()) +); + +-- Owner can remove anyone; a member can leave / decline. +CREATE POLICY "fm_delete" ON family_members FOR DELETE USING ( + is_family_owner(family_id) + OR user_id = auth.uid() + OR lower(invited_email) = lower(current_user_email()) +); + +-- ============================================================ +-- 5. Invite email +-- +-- Extends notify_invite_email() (20260707) with a 'family' kind. Replacing the function rather +-- than adding a second one keeps all three invite kinds on one code path; the grocery and +-- recipe-book triggers keep calling this same function and are unaffected. +-- ============================================================ + +CREATE OR REPLACE FUNCTION notify_invite_email() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_kind text := TG_ARGV[0]; + v_base_url text := invite_email_config('project_url'); + v_secret text := invite_email_config('invite_hook_secret'); + v_parent_id uuid; + v_invited_by uuid; +BEGIN + -- Only a genuine new invite: pending, not yet linked to a user (skips the accepted owner + -- auto-row and any pre-linked self-add). + IF NEW.status <> 'pending' OR NEW.user_id IS NOT NULL THEN + RETURN NEW; + END IF; + + -- Missing config: don't fail the invite insert — just skip the email. + IF v_base_url IS NULL OR v_secret IS NULL THEN + RETURN NEW; + END IF; + + -- The parent id column differs per table, so read it dynamically from the NEW row. + -- grocery_list_members and family_members carry invited_by; recipe_book_members does not, so we + -- default it to NULL there (the edge function falls back to the parent owner). + IF v_kind = 'grocery' THEN + v_parent_id := NEW.list_id; + v_invited_by := NEW.invited_by; + ELSIF v_kind = 'family' THEN + v_parent_id := NEW.family_id; + v_invited_by := NEW.invited_by; + ELSE + v_parent_id := NEW.recipe_book_id; + v_invited_by := NULL; + END IF; + + -- Async: net.http_post queues into net.http_request_queue and returns immediately, so a slow or + -- failing email never blocks or rolls back the invite insert. + PERFORM net.http_post( + url := rtrim(v_base_url, '/') || '/functions/v1/send-invite-email', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || v_secret + ), + body := jsonb_build_object( + 'kind', v_kind, + 'memberId', NEW.id, + 'parentId', v_parent_id, + 'invitedEmail', NEW.invited_email, + 'invitedBy', v_invited_by, + 'role', NEW.role + ) + ); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_notify_family_invite_email ON family_members; +CREATE TRIGGER trg_notify_family_invite_email + AFTER INSERT ON family_members + FOR EACH ROW EXECUTE FUNCTION notify_invite_email('family'); + +-- ============================================================ +-- 6. Realtime (idempotent) +-- +-- REPLICA IDENTITY FULL so UPDATE/DELETE events still carry enough of the old row to survive RLS +-- evaluation — without it those events are silently dropped for subscribers +-- (see 20260711_grocery_items_replica_identity_full.sql). +-- ============================================================ + +ALTER TABLE families REPLICA IDENTITY FULL; +ALTER TABLE family_members REPLICA IDENTITY FULL; + +DO $$ BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE families; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE family_members; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; From 823895928b9a013d42a5f0f003fe49ed3d36180b Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:14:55 -0700 Subject: [PATCH 3/9] feat(family): add Family and FamilyMember local tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local cache for the families/family_members tables added in the previous commit. Nothing consumes the queries yet — the repository that does lands next. Deliberately not offline-first: unlike GroceryList and RecipeBook these carry no clientId / isDirty / getUnsynced columns. Membership is inherently a server concept — creating a family offline could collide with an invite accepted on another device, and the one-family-per-user rule can only be arbitrated by the database. So these are read caches refreshed from the server, which is also how RecipeBookCollaborationRepository treats collaboration state. Members are cached locally (grocery's model) rather than fetched per-view (recipe books' model) so the Family screen can expose an observable Flow and render offline. Schema goes to version 12; verifyCommonMainDatabaseMigration passes. Co-Authored-By: Claude Opus 5 --- .../client/database/di/DatabaseComponent.kt | 11 +++++ .../plusmobileapps/chefmate/database/11.sqm | 29 +++++++++++++ .../chefmate/database/Family.sq | 33 +++++++++++++++ .../chefmate/database/FamilyMember.sq | 38 ++++++++++++++++++ .../src/commonMain/sqldelight/schema/12.db | Bin 0 -> 208896 bytes 5 files changed, 111 insertions(+) create mode 100644 client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm create mode 100644 client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/Family.sq create mode 100644 client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/FamilyMember.sq create mode 100644 client/database/core/src/commonMain/sqldelight/schema/12.db diff --git a/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt b/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt index 92ca93208..3b3174a6a 100644 --- a/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt +++ b/client/database/core/src/commonMain/kotlin/com/plusmobileapps/chefmate/client/database/di/DatabaseComponent.kt @@ -7,6 +7,8 @@ import com.plusmobileapps.chefmate.database.BrowserHistoryQueries import com.plusmobileapps.chefmate.database.CategoryQueries import com.plusmobileapps.chefmate.database.CookingSessionQueries import com.plusmobileapps.chefmate.database.Database +import com.plusmobileapps.chefmate.database.FamilyMemberQueries +import com.plusmobileapps.chefmate.database.FamilyQueries import com.plusmobileapps.chefmate.database.GroceryAutocompleteItemQueries import com.plusmobileapps.chefmate.database.GroceryListMemberQueries import com.plusmobileapps.chefmate.database.GroceryListQueries @@ -94,4 +96,13 @@ interface DatabaseComponent { @Provides fun providesGroceryAutocompleteItemQueries(database: Database): GroceryAutocompleteItemQueries = database.groceryAutocompleteItemQueries + + @SingleIn(AppScope::class) + @Provides + fun providesFamilyQueries(database: Database): FamilyQueries = database.familyQueries + + @SingleIn(AppScope::class) + @Provides + fun providesFamilyMemberQueries(database: Database): FamilyMemberQueries = + database.familyMemberQueries } diff --git a/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm new file mode 100644 index 000000000..f3c53d485 --- /dev/null +++ b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/11.sqm @@ -0,0 +1,29 @@ +-- Family collaboration (see Family.sq / FamilyMember.sq). A family is a group of accounts that +-- share grocery lists, recipe books, and the meal plan. Both tables are read caches of server +-- state, not offline-first sync targets — see the comments in Family.sq for why. This migration +-- adds only the group and its membership cache; the familyRemoteId columns that scope the three +-- domains land in later phases. +CREATE TABLE Family ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + remoteId TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + ownerId TEXT, + createdAt TEXT NOT NULL DEFAULT (datetime('now')), + updatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE FamilyMember ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + familyLocalId INTEGER NOT NULL, + remoteId TEXT UNIQUE, + userId TEXT, + userEmail TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'pending', + isOwner INTEGER NOT NULL DEFAULT 0, + displayName TEXT, + avatarUrl TEXT, + FOREIGN KEY (familyLocalId) REFERENCES Family(id) ON DELETE CASCADE +); + +CREATE INDEX idx_family_member_family ON FamilyMember(familyLocalId); diff --git a/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/Family.sq b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/Family.sq new file mode 100644 index 000000000..6679c3792 --- /dev/null +++ b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/Family.sq @@ -0,0 +1,33 @@ +-- Read cache of the user's family. A user belongs to at most one family (enforced server-side by a +-- partial unique index on family_members), so this table holds zero or one row. +-- +-- Deliberately NOT offline-first: unlike GroceryList / RecipeBook there are no clientId / isDirty / +-- getUnsynced columns. Membership is inherently a server concept — creating a family offline could +-- collide with an invite accepted on another device, and the "one family" rule can only be arbitrated +-- by the database. Mutations therefore go remote-first and this table is refreshed from the result, +-- which is also how RecipeBookCollaborationRepository treats collaboration state. +CREATE TABLE Family ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + remoteId TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + ownerId TEXT, + createdAt TEXT NOT NULL DEFAULT (datetime('now')), + updatedAt TEXT NOT NULL DEFAULT (datetime('now')) +); + +getCurrent: +SELECT * FROM Family LIMIT 1; + +getByRemoteId: +SELECT * FROM Family WHERE remoteId = ?; + +upsert: +INSERT INTO Family (remoteId, name, ownerId, createdAt, updatedAt) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT(remoteId) DO UPDATE SET + name = excluded.name, + ownerId = excluded.ownerId, + updatedAt = excluded.updatedAt; + +deleteAll: +DELETE FROM Family; diff --git a/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/FamilyMember.sq b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/FamilyMember.sq new file mode 100644 index 000000000..0670afb32 --- /dev/null +++ b/client/database/core/src/commonMain/sqldelight/com/plusmobileapps/chefmate/database/FamilyMember.sq @@ -0,0 +1,38 @@ +-- Read cache of the family's members, refreshed wholesale from the family_members_with_profiles +-- RPC. Cached rather than fetched per-view so the Family screen can expose an observable Flow and +-- render offline; recipe-book collaboration went remote-only and can only offer one-shot suspend +-- reads, which is the shape this deliberately avoids repeating. +-- +-- The owner row is synthesized server-side by the RPC, so it arrives with a null remoteId — isOwner +-- distinguishes it rather than role alone. +CREATE TABLE FamilyMember ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + familyLocalId INTEGER NOT NULL, + remoteId TEXT UNIQUE, + userId TEXT, + userEmail TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'pending', + isOwner INTEGER NOT NULL DEFAULT 0, + displayName TEXT, + avatarUrl TEXT, + FOREIGN KEY (familyLocalId) REFERENCES Family(id) ON DELETE CASCADE +); + +CREATE INDEX idx_family_member_family ON FamilyMember(familyLocalId); + +-- Owner first, then accepted members, then pending/declined invites — matching the RPC's ordering +-- so the on-device list looks the same whether it came from cache or a fresh fetch. +getByFamilyId: +SELECT * FROM FamilyMember WHERE familyLocalId = ? +ORDER BY isOwner DESC, status = 'accepted' DESC, userEmail ASC; + +insert: +INSERT INTO FamilyMember (familyLocalId, remoteId, userId, userEmail, role, status, isOwner, displayName, avatarUrl) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +deleteByFamilyId: +DELETE FROM FamilyMember WHERE familyLocalId = ?; + +deleteAll: +DELETE FROM FamilyMember; diff --git a/client/database/core/src/commonMain/sqldelight/schema/12.db b/client/database/core/src/commonMain/sqldelight/schema/12.db new file mode 100644 index 0000000000000000000000000000000000000000..8b62e1bc6c0b6362a63d926888abf983e9f3da38 GIT binary patch literal 208896 zcmeI*Piz}keg|-kERixTTXqs>*6S!9*-^+@OO_e0o88qGDNT(O!W5}c+&k=if*4U;js< zAIAI*`dbKmj`}E#JUEoc>Cw@tZ=7>dQ~xvd&6S@{etr3eSFTLVPJA2tar~RHUyuLq zrJG}_Adddi$nWVtr}*>uotT)rCyZ##I(coq(&^1s)wXT8+tn4*s1b7~uUU3A9oV?J z|DzjmabZC?ykn~yItd(FC5;VYhIYJ_Eh(9@B9${w@`@BXETs|>{n6@Du25E1l#)~^ zmZd@^pO@B3xz$YRnear{oNf(L)CP4uHOc)9z!SmerZWr&Q$VcDM{*@ZJ0@RqGhXg+lo51 zMVfW3x#^u-*Q}PV?iADptDDuJZmG6vR!r6owr{yuQgSN=*C7?SYgsBO%SuTpWR>+G zY)Y%kQn5hWm{%x#HnX11EGY>&^Hxl(EF2@^ERtN@*{-+ua#L|}eqK0G+~}7`O>2=S zhVhbp>fOH1#lH8b7kaZ($q|8ct`m7U*ill!>gFW2m8;8rD1&EmmZeUM`cp>MMXwW# z39F}n9SQl9>oIYCJ}MmDHn30Cbnl)Ud!Jv8i?S>nXl^jFGz_$RtrN@Aj3&QzjSB>S zZ153ix=wwY$NEBV&ebe0zbEAR$(WeA9Tj;mUY*Q%b+>rg9fEt$+*m&`jcxj5`IKJSFn9Q^ z1Q&V!*x((Y?L8?v@#lu#mS)k%`U#?xkQWn2b$!0s)il3-WxpJci__D>;V)c{Rif%^ zy4v)APjCeO-obC)AK9UNCW4qZix%sZ?aA6`Q zW~ZYDg&)Ui+W&NU@9B74TwD|m=7T9PLuZ#YqtVg{os`+6;f-CaQ~3{1HH%9M#JkUmJ^w_wNfVV|f#Qi@a)+W{vc0j&q^tIhb-J z=V(cJuXrggCX>R!mu|Q-TK0u%uhMylx=FZ=F|I!D(BJ{B8O<$XTB=QFJ>7iYO^AU> zGP`0nP@w`_pTDR!~w?XG=p`_J)GAHD{MS)H2 z(ynO9ooHogKHH?v6@F`!Gv$wYsBg%VHLu=2c&ycZ=GVFUoYXBBJ-a@^jF?@E%T<9Z z1n1R0ME~H?XV~ab&oG|IpI3Wz(gn77i~jNM)KzD#ecky3KM;Tb1Rwwb2tWV=5P$## zAOHaf468uGxiPH&hc&#oF9<*Y0uX=z1Rwwb2tWV=5P*OyfcO9C2oQh(1Rwwb2tWV= z5P$##AOL~k7r^>|_+yMaga8B}009U<00Izz00bZa0SI9Ik3Ika2tWV=5P$##AOHaf zKmY;|7=8i#{r};QG42oo5P$##AOHafKmY;|fB*y_fcO9C0}y}!1Rwwb2tWV=5P$## zAOL~k7r^`f;g2!y5CRZ@00bZa0SG_<0uX=z1R#L-|L6k{fB*y_009U<00Izz00bZa zf#Dax`~TsOG42oo5P$##AOHafKmY;|fB*y_fcO9C0}y}!1Rwwb2tWV=5P$##AOL~k z7r^`f;g2!y5CRZ@00bZa0SG_<0uX=z1R#L-|L6k{fB*y_009U<00Izz00bZaf#Dax z`~TsOG42oo5P$##AOHafKmY;|fB*y_fcO9C0}y}!1Rwwb2tWV=5P$##AOL~k7r^`f z;g2!y5CRZ@00bZa0SG_<0uX=z1R#L-|L6k{fB*y_009U<00Izz00bZaf#Dax`~TsO zG42oo5P$##AOHafKmY;|fB*y_fcO9C0}y}!1Rwwb2tWV=5P$##AOL~k7r^`f;g2!y z5CRZ@00bZa0SG_<0uX=z1R!vA>c2;(F8yre+MiDS!`1tf|8nK0lV4x{;gu^BvlHLO zejNX1?BB+Ied%YHZVEpa#F5|Ae@^k|@jEdwcTX76nsxHpdZlYNsjBT$ya27L|21sG zy=HaAG-|}$$!nHfO$Rn^X5Na4m4)7>^(nSW8XLsq3bS2r@BipVTwGWX4)56NhE4*t zvlBu)-pZDgOj(i2nJ0Ng3LTbG35ot_btzXUD=SJ#Diq67p_0!_Yo*+3ru0mDsyvf2 zm2xpxpjE9Zh4P$RM5nixH)^V$3zubQn51FYgdQ#{UzDXvA@^BDVMp2)F}?lW9ZEyh zbat-a2Cp7NC;WbCNmY8nsNp_-Tt9ILpI<-Zbb*;JSom!r9D*7c-(^}+-VZ3CYs_EW6H}-Ngv zU1gnEmS!}oX?}ZZ@AIp1QI>@R%?$*9l;66>1%W>{_}KGd!-F6QYfW;N-p;`h zoBq)6q&(KOG%Ndp)LxQsTI-~phwRLnNvPn7f!i$y{=~(i)2Wk`O2K67HBZ+_)8_K5 zW-V!^y%X*?enR!`%&D!4cn4^EX}%MG?isbES#;`ff+*3Xk~k`<@XfBK`RyzF z<#=42o)!*&;c~1JRbSK9rvH0_Bk=bQe)Im|40Qz-Y^R6vX@l)=&}Nl)T78e5m*G$w zyx$w!&7PUbcg<)E6EQJ69W^NYI9AjCr^|a!$K&GSqHr)DOo5rUZPbiLODA+jWs`KpEGFK+FR+Z|EiNqb zs!f_T(z7|vg`($R%8{I-CFQ;1rMQ?(3I|`h;m&B;7plEVCm!l1;Woy&`nW@b2ef81 zw}@$}Hl5~l^L;lV24=YIirGMc{&OquVG)|Mv;FjfHhn1dOD)|_)Vma6MKx>PrVrc( zotTA^R`F0LkpUAfapRjbzMxUM$Xz^(?z9XFI`z1DS`byQKOWnA%8uwvSO zzM6@PIoguj(Jf(f*s5xC6@BpLc618%X@x&AmVYV5MMVxafDNds-#t4Y_^x#?bp5Du z&ei#{DZg$C@aRrVT$zc=|E!#|==Qs^^ZD-n+eailFSq*Ye0J^R#)$RasV_%P>EZ2X{WbQz%@SRb(m84Gv!Qc$ z4-+!W5mCYRvlLy9*!PfSqs3zEdzy zI;Fx_lK4t8YQS`}8v7K?k!;V6`?q4^C%2BSz%TaRpJl%n7jNAXzNX)?((kTW73|O3 z+(={l1|Mg(X7_$$%m!7RST$2?IkVlsP@@w{lYWm(r={$>WA9weq+fy2GlM&|2A!7k zUk_VG+pP8OBjy&Z)$+3ytFdL0R+)a?#_z4sr%8CPZP;|79X?sr4O1gM6+2(_mFOJ4 zJ27S#)U0K7i=KaE+GMA8C*SUu_5K&|o$NZ~&ko;ft-YaZH9tVzbx`N)v4p(;QjCk~ zwD9MG+l#}?qVS&YbGONo{Jd0tLA)>SCHsYDNx>PVw5?i_ z_qDS0+%y_$jE!;T+h@G@ zru18w7b<;R9+@NY_y3RajRsDE00bZa0SG_<0uX=z1Rwwb2wZRh{Qdt69zZl70uX=z z1Rwwb2tWV=5P$##AaD!;y#GH25>9~t1Rwwb2tWV=5P$##AOHafTyO!b|1Wp|(R>I% z00Izz00bZa0SG_<0uX?}F$D16{~rShr$7J#5P$##AOHafKmY;|fB*z8xB%Y&U+@5; z`4E5r1Rwwb2tWV=5P$##AOL}52;lwyF_3Ty1Rwwb2tWV=5P$##AOHafK;VK4;Qjvv z4$Nwnycd_a5zaAeQYhL>H(%+2!Cp{TI5I75g`a(?nct-e2 zYu3qYt^T@dlA6{c)eXaVSv9ts#4Ncxp3v<_*^-heD^jkoqU>u7{z^^ef?9ELbYVPRbiKRd?ub+>eV- z)57kf&UIC-T6>||)tb@VBBrIkA*S6E9 zw|@{9*J+y;j&4)CRi}NSURCXIr4NqRu5J|(ZSzjZ*z)eE92ZySh27~+tAc~U`^m9X$LFhj4AfX6LM(YO2x&B91K^Do~AW7s}`{=CsMj<*>poE zHD;4#$Jg26xll{qRNiS2Uq{Nh#JYC{M@C4};Y%CUmFcb2vQ@KRbMC*fIo*sH};heV&cNI&xOBzx!74j48xlFHsM9j8cq*6& z=@9&ChITaClH1WOF$tHKymox>=5}-n_Gtw>M!VTBrMRfj(bjE%mq0pC8UF*z9{ywL zUg-K!<(#YYWmA5Y6yVXFn7A?{jJWr2z0&ERa(jxqU1jB*+*M6?Ki}Pd{C-@VnGp^j z+vCB?6tSF_<^H^Fd4(5JWe1ZVp-Yl%Ep+qxaDaYlEsx=Bh5N} zW-QShPE$gN)xW!k33*{UCT6Fjg6(&$YTExK?S1rKT%4X34nB4R?eF)0-{FGn?>+qx zcP4a!`yMcuXC9S={PFFW_;fZ(IXHc*rh{AW@2`D7F3!#hhc#C_cr5sNno|!R8C?Fk zI~Dcjk)b}s`e-y=n?9Mj?(FPP8mgwV&d(26)6faOzcT`pY<`?%7o-ycyKP0C+G5?_ zJ6E%c&H^UX^iI`Y4ybDueTeTAoX3)r1=y9;EtSq`Dkf_$+qYaSDY=z`n~r+s*-}Ya zrt6AAR$2F^<0*|UHfW+P_P6$8%0uX=z1Rwwb z2tWV=5P-n>62SWZe8mH;fdB*`009U<00Izz00bZa0SKIh0M`F!p^7>o009U<00Izz z00bZa0SG_<0_RHr>;Ll=53~jX5P$##AOHafKmY;|fB*y_a25hs|DS~_>VyCUAOHaf zKmY;|fB*y_009V`F9EFo&sRLq8VEoD0uX=z1Rwwb2tWV=5P-m02w?qx7OJQd0uX=z V1Rwwb2tWV=5P$##AaK3}{vVL(pF98n literal 0 HcmV?d00001 From eae50d96755c82156f09f639f8f19bf9fb454404 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:15:53 -0700 Subject: [PATCH 4/9] feat(family): add the family repository, BLoC, and screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads (family, members, pending invites) come off the local cache so the screen renders offline; writes go remote-first and throw, because "one family per user" can only be arbitrated by the database. AlreadyInFamilyException carries that case so the UI can say "leave your current family first" instead of surfacing a raw 23505. Two roles, not the three grocery lists and recipe books use. A family implies trust — every member can edit everything shared with it, and the only distinction that matters is who administers the group. That's why the screen has just Owner and Members groups, and why there's no role picker on the invite form. The repository reads the family through the current_family() RPC rather than selecting from families: RLS deliberately lets a pending invitee read the family row they were invited to, so a blanket select would cache a family the user hasn't joined. Sign-out clears the cache alongside the other repositories. Co-Authored-By: Claude Opus 5 --- client/auth/usecase/impl/build.gradle.kts | 2 + .../auth/usecase/impl/SignOutUseCaseImpl.kt | 3 + .../usecase/impl/SignOutUseCaseImplTest.kt | 3 + client/composeApp/build.gradle.kts | 5 + .../di/BaseTestApplicationComponent.kt | 8 + .../family/core/impl-robots/build.gradle.kts | 13 + .../chefmate/family/robots/FamilyRobot.kt | 86 ++++ client/family/core/impl/build.gradle.kts | 29 ++ .../family/core/impl/FamilyBlocImpl.kt | 99 ++++ .../family/core/impl/FamilyViewModel.kt | 246 ++++++++++ .../family/core/impl/FamilyBlocImplTest.kt | 235 +++++++++ client/family/core/public/build.gradle.kts | 22 + .../composeResources/values/strings.xml | 48 ++ .../chefmate/family/core/FamilyBloc.kt | 142 ++++++ .../chefmate/family/core/FamilyTestTags.kt | 26 + .../chefmate/family/core/ui/FamilyScreen.kt | 444 ++++++++++++++++++ client/family/data/impl/build.gradle.kts | 35 ++ .../family/data/impl/FamilyRepositoryImpl.kt | 339 +++++++++++++ .../remote/SupabaseFamilyRemoteDataSource.kt | 135 ++++++ .../data/impl/FamilyRepositoryImplTest.kt | 238 ++++++++++ client/family/data/public/build.gradle.kts | 15 + .../chefmate/family/data/Family.kt | 28 ++ .../chefmate/family/data/FamilyMember.kt | 76 +++ .../chefmate/family/data/FamilyRepository.kt | 81 ++++ .../chefmate/family/data/FamilyRole.kt | 24 + .../data/remote/FamilyRemoteDataSource.kt | 53 +++ .../family/data/remote/RemoteFamilyModels.kt | 52 ++ client/family/data/testing/build.gradle.kts | 12 + .../testing/FakeFamilyRemoteDataSource.kt | 157 +++++++ .../data/testing/FakeFamilyRepository.kt | 135 ++++++ settings.gradle.kts | 12 + 31 files changed, 2803 insertions(+) create mode 100644 client/family/core/impl-robots/build.gradle.kts create mode 100644 client/family/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/robots/FamilyRobot.kt create mode 100644 client/family/core/impl/build.gradle.kts create mode 100644 client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImpl.kt create mode 100644 client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyViewModel.kt create mode 100644 client/family/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImplTest.kt create mode 100644 client/family/core/public/build.gradle.kts create mode 100644 client/family/core/public/src/commonMain/composeResources/values/strings.xml create mode 100644 client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyBloc.kt create mode 100644 client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyTestTags.kt create mode 100644 client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyScreen.kt create mode 100644 client/family/data/impl/build.gradle.kts create mode 100644 client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImpl.kt create mode 100644 client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/remote/SupabaseFamilyRemoteDataSource.kt create mode 100644 client/family/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImplTest.kt create mode 100644 client/family/data/public/build.gradle.kts create mode 100644 client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/Family.kt create mode 100644 client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyMember.kt create mode 100644 client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRepository.kt create mode 100644 client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRole.kt create mode 100644 client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/FamilyRemoteDataSource.kt create mode 100644 client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/RemoteFamilyModels.kt create mode 100644 client/family/data/testing/build.gradle.kts create mode 100644 client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRemoteDataSource.kt create mode 100644 client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRepository.kt diff --git a/client/auth/usecase/impl/build.gradle.kts b/client/auth/usecase/impl/build.gradle.kts index ee173ac37..5a4408664 100644 --- a/client/auth/usecase/impl/build.gradle.kts +++ b/client/auth/usecase/impl/build.gradle.kts @@ -7,6 +7,7 @@ kotlin { implementation(projects.client.shared) implementation(projects.client.aichat.public) implementation(projects.client.auth.data.public) + implementation(projects.client.family.data.public) implementation(projects.client.grocery.data.public) implementation(projects.client.meal.data.public) implementation(projects.client.recipe.data.public) @@ -14,6 +15,7 @@ kotlin { } commonTest.dependencies { implementation(projects.client.auth.data.testing) + implementation(projects.client.family.data.testing) implementation(projects.client.grocery.data.testing) implementation(projects.client.meal.data.testing) implementation(projects.client.recipe.data.testing) diff --git a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt index 20e23616d..64580411d 100644 --- a/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt +++ b/client/auth/usecase/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImpl.kt @@ -4,6 +4,7 @@ import com.plusmobileapps.chefmate.aichat.AiChatLocalDataCleaner import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository import com.plusmobileapps.chefmate.auth.usecase.SignOutUseCase import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.family.data.FamilyRepository import com.plusmobileapps.chefmate.grocery.data.GroceryAutocompleteRepository import com.plusmobileapps.chefmate.grocery.data.GroceryRepository import com.plusmobileapps.chefmate.meal.data.MealPlanRepository @@ -25,10 +26,12 @@ class SignOutUseCaseImpl( private val recipeBookRepository: RecipeBookRepository, private val categoryRepository: CategoryRepository, private val groceryAutocompleteRepository: GroceryAutocompleteRepository, + private val familyRepository: FamilyRepository, private val aiChatLocalDataCleaner: AiChatLocalDataCleaner, ) : SignOutUseCase { override suspend fun invoke() { authenticationRepository.signOut() + familyRepository.clearLocalData() mealPlanRepository.clearLocalData() recipeRepository.clearLocalData() recipeBookRepository.clearLocalData() diff --git a/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt b/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt index af20590b4..b4e702606 100644 --- a/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt +++ b/client/auth/usecase/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/auth/usecase/impl/SignOutUseCaseImplTest.kt @@ -4,6 +4,7 @@ package com.plusmobileapps.chefmate.auth.usecase.impl import com.plusmobileapps.chefmate.aichat.AiChatLocalDataCleaner import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository +import com.plusmobileapps.chefmate.family.data.testing.FakeFamilyRepository import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryAutocompleteRepository import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryRepository import com.plusmobileapps.chefmate.meal.data.testing.FakeMealPlanRepository @@ -35,6 +36,7 @@ class SignOutUseCaseImplTest { ) private val categoryRepository = FakeCategoryRepository(categories) private val groceryAutocompleteRepository = FakeGroceryAutocompleteRepository() + private val familyRepository = FakeFamilyRepository() private var aiChatCleared = false private val aiChatLocalDataCleaner = AiChatLocalDataCleaner { aiChatCleared = true } @@ -47,6 +49,7 @@ class SignOutUseCaseImplTest { recipeBookRepository = recipeBookRepository, categoryRepository = categoryRepository, groceryAutocompleteRepository = groceryAutocompleteRepository, + familyRepository = familyRepository, aiChatLocalDataCleaner = aiChatLocalDataCleaner, ) diff --git a/client/composeApp/build.gradle.kts b/client/composeApp/build.gradle.kts index 9b8626952..67492b54a 100644 --- a/client/composeApp/build.gradle.kts +++ b/client/composeApp/build.gradle.kts @@ -92,6 +92,10 @@ kotlin { api(projects.client.cook.public) api(projects.client.featureflag.impl) api(projects.client.featureflag.public) + api(projects.client.family.core.impl) + api(projects.client.family.core.public) + api(projects.client.family.data.impl) + api(projects.client.family.data.public) api(projects.client.grocery.autocomplete.impl) api(projects.client.grocery.autocomplete.public) api(projects.client.grocery.data.impl) @@ -164,6 +168,7 @@ kotlin { implementation(projects.client.browser.implRobots) implementation(projects.client.featureflag.testing) implementation(projects.client.recipe.categories.implRobots) + implementation(projects.client.family.core.implRobots) implementation(projects.client.grocery.autocomplete.implRobots) implementation(projects.client.grocery.core.implRobots) implementation(projects.client.recipe.core.implRobots) diff --git a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt index c12ff713d..a26236030 100644 --- a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt +++ b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/di/BaseTestApplicationComponent.kt @@ -6,6 +6,8 @@ import com.plusmobileapps.chefmate.database.BrowserHistoryQueries import com.plusmobileapps.chefmate.database.CategoryQueries import com.plusmobileapps.chefmate.database.CookingSessionQueries import com.plusmobileapps.chefmate.database.Database +import com.plusmobileapps.chefmate.database.FamilyMemberQueries +import com.plusmobileapps.chefmate.database.FamilyQueries import com.plusmobileapps.chefmate.database.GroceryAutocompleteItemQueries import com.plusmobileapps.chefmate.database.GroceryListMemberQueries import com.plusmobileapps.chefmate.database.GroceryListQueries @@ -38,6 +40,12 @@ abstract class BaseTestApplicationComponent : TestApplicationComponent { @Provides fun providesGroceryQueries(database: Database): GroceryQueries = database.groceryQueries + @Provides fun providesFamilyQueries(database: Database): FamilyQueries = database.familyQueries + + @Provides + fun providesFamilyMemberQueries(database: Database): FamilyMemberQueries = + database.familyMemberQueries + @Provides fun providesGroceryListQueries(database: Database): GroceryListQueries = database.groceryListQueries diff --git a/client/family/core/impl-robots/build.gradle.kts b/client/family/core/impl-robots/build.gradle.kts new file mode 100644 index 000000000..cde98615f --- /dev/null +++ b/client/family/core/impl-robots/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.compose) +} + +kotlin { + sourceSets { commonMain.dependencies { implementation(projects.client.family.core.public) } } +} + +plusLibrary { + namespace = "com.plusmobileapps.chefmate.family.robots" + uiTest = true +} diff --git a/client/family/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/robots/FamilyRobot.kt b/client/family/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/robots/FamilyRobot.kt new file mode 100644 index 000000000..7f1009d8e --- /dev/null +++ b/client/family/core/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/robots/FamilyRobot.kt @@ -0,0 +1,86 @@ +@file:OptIn(ExperimentalTestApi::class) + +package com.plusmobileapps.chefmate.family.robots + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.waitUntilExactlyOneExists +import com.plusmobileapps.chefmate.family.core.FamilyTestTags + +class FamilyRobot(private val test: ComposeUiTest) { + + /** The family state loads asynchronously, so wait for the screen before asserting on it. */ + fun awaitDisplayed(): FamilyRobot = apply { + test.waitUntilExactlyOneExists(hasTestTag(FamilyTestTags.SCREEN)) + } + + fun assertDisplayed(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.SCREEN).assertIsDisplayed() + } + + /** With no family yet, the screen shows the create form rather than a member list. */ + fun assertCreateFormShown(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.CREATE_BUTTON).assertIsDisplayed() + } + + fun typeFamilyName(name: String): FamilyRobot = apply { + // The test tag sits on the PlusTextField wrapper; the editable node is the inner field. + test + .onNode( + hasSetTextAction() and hasAnyAncestor(hasTestTag(FamilyTestTags.CREATE_NAME_FIELD)) + ) + .performTextReplacement(name) + } + + fun createFamily(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.CREATE_BUTTON).performClick() + } + + fun typeInviteEmail(email: String): FamilyRobot = apply { + test + .onNode( + hasSetTextAction() and hasAnyAncestor(hasTestTag(FamilyTestTags.INVITE_EMAIL_FIELD)) + ) + .performTextReplacement(email) + } + + fun sendInvite(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.INVITE_BUTTON).performClick() + } + + fun assertMembersShown(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.MEMBERS).assertIsDisplayed() + } + + /** The invite controls are owner-only, so this asserts the current user is not the owner. */ + fun assertCannotInvite(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.INVITE_BUTTON).assertDoesNotExist() + } + + fun leaveFamily(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.LEAVE_BUTTON).performClick() + } + + fun deleteFamily(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.DELETE_BUTTON).performClick() + } + + fun assertDeleteNotShown(): FamilyRobot = apply { + test.onNodeWithTag(FamilyTestTags.DELETE_BUTTON).assertDoesNotExist() + } + + /** Taps the confirm button in a leave/delete/remove dialog. */ + fun confirmDialog(text: String): FamilyRobot = apply { + test.onNodeWithText(text).performClick() + } +} + +fun ComposeUiTest.family(): FamilyRobot = FamilyRobot(this) diff --git a/client/family/core/impl/build.gradle.kts b/client/family/core/impl/build.gradle.kts new file mode 100644 index 000000000..e841a5b57 --- /dev/null +++ b/client/family/core/impl/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.compose) +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.client.family.core.public) + implementation(projects.client.family.data.public) + implementation(projects.client.text.public) + implementation(projects.client.ui.public) + implementation(projects.client.shared) + implementation(projects.client.auth.data.public) + implementation(libs.arkivanov.decompose.core) + implementation(compose.components.resources) + } + commonTest.dependencies { + implementation(projects.client.family.data.testing) + implementation(projects.client.auth.data.testing) + } + } +} + +plusLibrary { + namespace = "com.plusmobileapps.chefmate.family.core.impl" + enableDi = true + enableTesting = true +} diff --git a/client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImpl.kt b/client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImpl.kt new file mode 100644 index 000000000..a1ec42d86 --- /dev/null +++ b/client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImpl.kt @@ -0,0 +1,99 @@ +package com.plusmobileapps.chefmate.family.core.impl + +import com.plusmobileapps.chefmate.BlocContext +import com.plusmobileapps.chefmate.Consumer +import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.family.core.FamilyBloc +import com.plusmobileapps.chefmate.family.core.FamilyBloc.Model +import com.plusmobileapps.chefmate.family.core.FamilyBloc.Output +import com.plusmobileapps.chefmate.getViewModel +import com.plusmobileapps.metro.extensions.assistedfactory.ContributesAssistedFactory +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.Provider +import kotlinx.coroutines.flow.StateFlow + +@AssistedInject +@ContributesAssistedFactory(scope = AppScope::class, assistedFactory = FamilyBloc.Factory::class) +class FamilyBlocImpl( + @Assisted context: BlocContext, + @Assisted private val output: Consumer, + viewModelFactory: Provider, +) : FamilyBloc, BlocContext by context { + + private val viewModel = instanceKeeper.getViewModel { viewModelFactory() } + + override val state: StateFlow = viewModel.state + + override fun onBack() { + output.onNext(Output.Back) + } + + override fun onSignInClicked() { + output.onNext(Output.OpenSignIn) + } + + override fun onSignUpClicked() { + output.onNext(Output.OpenSignUp) + } + + override fun onNewFamilyNameChanged(name: String) { + viewModel.onNewFamilyNameChanged(name) + } + + override fun onCreateFamilyClicked() { + viewModel.createFamily() + } + + override fun onRenameClicked() { + viewModel.startRename() + } + + override fun onEditingNameChanged(name: String) { + viewModel.onEditingNameChanged(name) + } + + override fun onRenameConfirmed() { + viewModel.confirmRename() + } + + override fun onRenameCancelled() { + viewModel.cancelRename() + } + + override fun onInviteEmailChanged(email: String) { + viewModel.onInviteEmailChanged(email) + } + + override fun onInviteClicked() { + viewModel.invite() + } + + override fun onRemoveMemberClicked(memberId: String) { + viewModel.startRemoveMember(memberId) + } + + override fun onConfirmRemoveMember() { + viewModel.confirmRemoveMember() + } + + override fun onDismissRemoveMember() { + viewModel.dismissRemoveMember() + } + + override fun onLeaveFamilyClicked() { + viewModel.startFamilyAction(FamilyBloc.FamilyAction.LEAVE) + } + + override fun onDeleteFamilyClicked() { + viewModel.startFamilyAction(FamilyBloc.FamilyAction.DELETE) + } + + override fun onConfirmFamilyAction() { + viewModel.confirmFamilyAction() + } + + override fun onDismissFamilyAction() { + viewModel.dismissFamilyAction() + } +} diff --git a/client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyViewModel.kt b/client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyViewModel.kt new file mode 100644 index 000000000..4a67f87d9 --- /dev/null +++ b/client/family/core/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyViewModel.kt @@ -0,0 +1,246 @@ +package com.plusmobileapps.chefmate.family.core.impl + +import chefmate.client.family.core.public.generated.resources.Res +import chefmate.client.family.core.public.generated.resources.family_action_error +import chefmate.client.family.core.public.generated.resources.family_already_in_family +import chefmate.client.family.core.public.generated.resources.family_create_error +import chefmate.client.family.core.public.generated.resources.family_invite_error +import chefmate.client.family.core.public.generated.resources.family_rename_error +import com.plusmobileapps.chefmate.ViewModel +import com.plusmobileapps.chefmate.auth.data.AuthState +import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository +import com.plusmobileapps.chefmate.di.Main +import com.plusmobileapps.chefmate.family.core.FamilyBloc.FamilyAction +import com.plusmobileapps.chefmate.family.core.FamilyBloc.Model +import com.plusmobileapps.chefmate.family.data.AlreadyInFamilyException +import com.plusmobileapps.chefmate.family.data.FamilyRepository +import com.plusmobileapps.chefmate.text.TextData +import com.plusmobileapps.chefmate.text.asTextData +import dev.zacsweers.metro.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@Inject +class FamilyViewModel( + @Main mainContext: CoroutineContext, + private val repository: FamilyRepository, + authenticationRepository: AuthenticationRepository, +) : ViewModel(mainContext) { + + /** + * Everything the user is typing or confirming. Kept separate from the repository's state so a + * realtime member update doesn't wipe a half-typed invite address. + */ + private data class UiState( + val newFamilyName: String = "", + val isCreating: Boolean = false, + val createError: TextData? = null, + val editingName: String? = null, + val isRenaming: Boolean = false, + val renameError: TextData? = null, + val inviteEmail: String = "", + val isInviting: Boolean = false, + val inviteError: TextData? = null, + val removingMemberId: String? = null, + val pendingFamilyAction: FamilyAction? = null, + val isRemovingFamily: Boolean = false, + val familyActionError: TextData? = null, + ) + + private val uiState = MutableStateFlow(UiState()) + + val state: StateFlow = + combine( + repository.family, + repository.members(), + authenticationRepository.state, + uiState, + ) { family, members, authState, ui -> + Model( + isLoading = false, + isSignedIn = + (authState as? AuthState.Authenticated)?.user?.isAnonymous == false, + family = family, + members = members.toImmutableList(), + isOwner = family?.isOwnedByCurrentUser == true, + newFamilyName = ui.newFamilyName, + isCreating = ui.isCreating, + createError = ui.createError, + editingName = ui.editingName, + isRenaming = ui.isRenaming, + renameError = ui.renameError, + inviteEmail = ui.inviteEmail, + isInviting = ui.isInviting, + inviteError = ui.inviteError, + // Resolve the id against the live member list so a member removed elsewhere + // takes the dialog down with them instead of stranding it. Guard the null id + // explicitly — the owner's synthesized row also has a null id, so an unguarded + // `it.id == removingMemberId` would match the owner whenever no removal is + // pending and pop the dialog open by itself. + removingMember = + ui.removingMemberId?.let { id -> members.firstOrNull { it.id == id } }, + pendingFamilyAction = ui.pendingFamilyAction, + isRemovingFamily = ui.isRemovingFamily, + familyActionError = ui.familyActionError, + ) + } + .stateIn( + scope, + SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + Model(members = persistentListOf()), + ) + + init { + // The cache may have gone stale since the last realtime emission (app backgrounded, invite + // accepted elsewhere), so pull once on entry. + scope.launch { runCatching { repository.refresh() } } + } + + fun onNewFamilyNameChanged(name: String) { + uiState.update { it.copy(newFamilyName = name, createError = null) } + } + + fun createFamily() { + val name = uiState.value.newFamilyName.trim() + if (name.isBlank() || uiState.value.isCreating) return + uiState.update { it.copy(isCreating = true, createError = null) } + scope.launch { + runCatching { repository.createFamily(name) } + .onSuccess { uiState.update { it.copy(isCreating = false, newFamilyName = "") } } + .onFailure { error -> + uiState.update { + it.copy(isCreating = false, createError = error.createMessage()) + } + } + } + } + + fun startRename() { + uiState.update { + it.copy(editingName = repository.family.value?.name.orEmpty(), renameError = null) + } + } + + fun onEditingNameChanged(name: String) { + uiState.update { it.copy(editingName = name, renameError = null) } + } + + fun cancelRename() { + uiState.update { it.copy(editingName = null, isRenaming = false, renameError = null) } + } + + fun confirmRename() { + val name = uiState.value.editingName?.trim().orEmpty() + if (name.isBlank() || uiState.value.isRenaming) return + uiState.update { it.copy(isRenaming = true) } + scope.launch { + runCatching { repository.renameFamily(name) } + .onSuccess { + uiState.update { + it.copy(isRenaming = false, editingName = null, renameError = null) + } + } + .onFailure { + // Keep the field open with what they typed so the retry doesn't lose it. + uiState.update { + it.copy( + isRenaming = false, + renameError = Res.string.family_rename_error.asTextData(), + ) + } + } + } + } + + fun onInviteEmailChanged(email: String) { + uiState.update { it.copy(inviteEmail = email, inviteError = null) } + } + + fun invite() { + val email = uiState.value.inviteEmail.trim() + if (email.isBlank() || uiState.value.isInviting) return + uiState.update { it.copy(isInviting = true, inviteError = null) } + scope.launch { + runCatching { repository.invite(email) } + .onSuccess { uiState.update { it.copy(isInviting = false, inviteEmail = "") } } + .onFailure { + uiState.update { + it.copy( + isInviting = false, + inviteError = Res.string.family_invite_error.asTextData(), + ) + } + } + } + } + + fun startRemoveMember(memberId: String) { + uiState.update { it.copy(removingMemberId = memberId) } + } + + fun dismissRemoveMember() { + uiState.update { it.copy(removingMemberId = null) } + } + + fun confirmRemoveMember() { + val memberId = uiState.value.removingMemberId ?: return + uiState.update { it.copy(removingMemberId = null) } + scope.launch { + runCatching { repository.removeMember(memberId) } + .onFailure { + uiState.update { + it.copy(familyActionError = Res.string.family_action_error.asTextData()) + } + } + } + } + + fun startFamilyAction(action: FamilyAction) { + uiState.update { it.copy(pendingFamilyAction = action, familyActionError = null) } + } + + fun dismissFamilyAction() { + uiState.update { it.copy(pendingFamilyAction = null) } + } + + fun confirmFamilyAction() { + val action = uiState.value.pendingFamilyAction ?: return + uiState.update { it.copy(pendingFamilyAction = null, isRemovingFamily = true) } + scope.launch { + runCatching { + when (action) { + FamilyAction.DELETE -> repository.deleteFamily() + FamilyAction.LEAVE -> repository.leaveFamily() + } + } + .onSuccess { uiState.update { it.copy(isRemovingFamily = false) } } + .onFailure { + uiState.update { + it.copy( + isRemovingFamily = false, + familyActionError = Res.string.family_action_error.asTextData(), + ) + } + } + } + } + + private fun Throwable.createMessage() = + if (this is AlreadyInFamilyException) { + Res.string.family_already_in_family.asTextData() + } else { + Res.string.family_create_error.asTextData() + } + + private companion object { + const val STOP_TIMEOUT_MS = 5000L + } +} diff --git a/client/family/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImplTest.kt b/client/family/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImplTest.kt new file mode 100644 index 000000000..0c5bbbfd5 --- /dev/null +++ b/client/family/core/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/core/impl/FamilyBlocImplTest.kt @@ -0,0 +1,235 @@ +@file:Suppress("FunctionName") + +package com.plusmobileapps.chefmate.family.core.impl + +import app.cash.turbine.test +import com.plusmobileapps.chefmate.auth.data.AuthState +import com.plusmobileapps.chefmate.auth.data.ChefMateUser +import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository +import com.plusmobileapps.chefmate.family.core.FamilyBloc +import com.plusmobileapps.chefmate.family.data.AlreadyInFamilyException +import com.plusmobileapps.chefmate.family.data.Family +import com.plusmobileapps.chefmate.family.data.FamilyInvite +import com.plusmobileapps.chefmate.family.data.FamilyMember +import com.plusmobileapps.chefmate.family.data.testing.FakeFamilyRepository +import com.plusmobileapps.chefmate.testing.TestBlocContext +import com.plusmobileapps.chefmate.testing.TestConsumer +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlin.test.Test +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest + +class FamilyBlocImplTest { + + private val context = TestBlocContext.create() + private val output = TestConsumer() + + private val familyState = MutableStateFlow(null) + private val membersState = MutableStateFlow>(emptyList()) + private val invitesState = MutableStateFlow>(emptyList()) + private val repository = FakeFamilyRepository(familyState, membersState, invitesState) + + private val authRepository = + FakeAuthenticationRepository().apply { + setState( + AuthState.Authenticated( + ChefMateUser( + userId = "id-1", + userName = "Chef", + userEmail = "chef@example.com", + userProfileImageUrl = null, + ) + ) + ) + } + + private fun bloc() = + FamilyBlocImpl( + context = context, + output = output, + viewModelFactory = { + FamilyViewModel( + mainContext = kotlinx.coroutines.Dispatchers.Unconfined, + repository = repository, + authenticationRepository = authRepository, + ) + }, + ) + + @Test + fun When_not_in_a_family_Then_the_model_has_no_family_and_no_members() = runTest { + bloc().state.test { + val model = awaitItem() + model.isLoading shouldBe false + model.family shouldBe null + model.members shouldBe emptyList() + model.isOwner shouldBe false + } + } + + @Test + fun When_anonymous_Then_the_screen_reports_signed_out() = runTest { + authRepository.setAnonymous() + + bloc().state.test { awaitItem().isSignedIn shouldBe false } + } + + @Test + fun When_creating_a_family_Then_the_name_field_is_cleared() = runTest { + val bloc = bloc() + + bloc.state.test { + awaitItem() + bloc.onNewFamilyNameChanged("The Hendersons") + awaitItem().newFamilyName shouldBe "The Hendersons" + + bloc.onCreateFamilyClicked() + + // isCreating flips true then back to false with the field cleared. + skipItems(1) + val created = awaitItem() + created.newFamilyName shouldBe "" + created.family shouldNotBe null + } + } + + @Test + fun When_creating_fails_because_already_in_a_family_Then_a_specific_error_is_shown() = runTest { + repository.errorToThrow = AlreadyInFamilyException() + val bloc = bloc() + + bloc.state.test { + awaitItem() + bloc.onNewFamilyNameChanged("Second Family") + awaitItem() + + bloc.onCreateFamilyClicked() + skipItems(1) + + val failed = awaitItem() + failed.createError shouldNotBe null + failed.isCreating shouldBe false + // The typed name is kept so the user can act on the error without retyping. + failed.newFamilyName shouldBe "Second Family" + } + } + + @Test + fun When_the_user_owns_the_family_Then_owner_controls_are_enabled() = runTest { + familyState.value = Family.Sample + membersState.value = FamilyMember.Samples + + bloc().state.test { + val model = awaitItem() + model.isOwner shouldBe true + model.members.size shouldBe FamilyMember.Samples.size + } + } + + @Test + fun When_the_user_is_a_member_Then_they_are_not_the_owner() = runTest { + familyState.value = Family.Sample.copy(isOwnedByCurrentUser = false) + + bloc().state.test { awaitItem().isOwner shouldBe false } + } + + @Test + fun When_inviting_Then_the_email_field_is_cleared_and_the_invite_is_sent() = runTest { + familyState.value = Family.Sample + val bloc = bloc() + + bloc.state.test { + awaitItem() + bloc.onInviteEmailChanged("alex@example.com") + awaitItem() + + bloc.onInviteClicked() + skipItems(1) + + awaitItem().inviteEmail shouldBe "" + } + repository.invitedEmails shouldBe listOf("alex@example.com") + } + + @Test + fun When_renaming_Then_the_inline_field_opens_seeded_with_the_current_name() = runTest { + familyState.value = Family.Sample + val bloc = bloc() + + bloc.state.test { + awaitItem() + bloc.onRenameClicked() + awaitItem().editingName shouldBe Family.Sample.name + + bloc.onRenameCancelled() + awaitItem().editingName shouldBe null + } + } + + @Test + fun When_removing_a_member_Then_confirmation_is_required_first() = runTest { + familyState.value = Family.Sample + membersState.value = FamilyMember.Samples + val bloc = bloc() + + bloc.state.test { + awaitItem() + bloc.onRemoveMemberClicked("member-2") + awaitItem().removingMember?.email shouldBe FamilyMember.SampleMember.email + + bloc.onConfirmRemoveMember() + awaitItem().removingMember shouldBe null + // The member list then re-emits without them. + awaitItem().members.none { it.id == "member-2" } shouldBe true + } + repository.removedMemberIds shouldBe listOf("member-2") + } + + @Test + fun When_dismissing_the_remove_dialog_Then_nothing_is_removed() = runTest { + familyState.value = Family.Sample + membersState.value = FamilyMember.Samples + val bloc = bloc() + + bloc.state.test { + awaitItem() + bloc.onRemoveMemberClicked("member-2") + awaitItem() + + bloc.onDismissRemoveMember() + awaitItem().removingMember shouldBe null + } + repository.removedMemberIds shouldBe emptyList() + } + + @Test + fun When_leaving_the_family_Then_it_is_confirmed_before_acting() = runTest { + familyState.value = Family.Sample.copy(isOwnedByCurrentUser = false) + val bloc = bloc() + + bloc.state.test { + awaitItem() + bloc.onLeaveFamilyClicked() + awaitItem().pendingFamilyAction shouldBe FamilyBloc.FamilyAction.LEAVE + + bloc.onConfirmFamilyAction() + skipItems(1) + awaitItem().family shouldBe null + } + } + + @Test + fun When_back_is_clicked_Then_the_bloc_outputs_Back() = runTest { + bloc().onBack() + + output.values.last() shouldBe FamilyBloc.Output.Back + } + + @Test + fun When_signed_out_and_sign_in_is_tapped_Then_the_auth_flow_is_requested() = runTest { + bloc().onSignInClicked() + + output.values.last() shouldBe FamilyBloc.Output.OpenSignIn + } +} diff --git a/client/family/core/public/build.gradle.kts b/client/family/core/public/build.gradle.kts new file mode 100644 index 000000000..4eceffa25 --- /dev/null +++ b/client/family/core/public/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.compose) +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.arkivanov.decompose.core) + api(projects.client.text.public) + api(projects.client.ui.public) + api(projects.client.family.data.public) + implementation(projects.client.shared) + implementation(projects.client.util.public) + implementation(compose.components.resources) + } + } +} + +compose { resources { publicResClass = true } } + +plusLibrary { namespace = "com.plusmobileapps.chefmate.family.core" } diff --git a/client/family/core/public/src/commonMain/composeResources/values/strings.xml b/client/family/core/public/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 000000000..be92ff584 --- /dev/null +++ b/client/family/core/public/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,48 @@ + + + Family + + Sign in to start a family + A family shares grocery lists, recipe books, and a meal plan across everyone’s devices, so it needs an account. + + You’re not in a family yet + Create one to share grocery lists, recipe books, and a meal plan with the people you cook for. Everyone you invite can add and edit what’s shared. + Family name + Create family + Couldn’t create the family. Check your connection and try again. + You’re already in a family. Leave it first to join or create another. + + Rename family + Save + Cancel + Couldn’t rename the family. Check your connection and try again. + + Invited + Declined + Owner + Members + Remove %1$s + + Invite by email + Send invite + Couldn’t send that invite. Check the address and your connection. + They’ll get an email and an in-app notification. Nothing is shared until they accept. + + Remove member? + {email} will lose access to everything shared with the family. + Remove + Cancel + + Leave family + Leave this family? + You’ll lose access to the grocery lists, recipe books, and meal plan shared with the family. Anything you created stays yours. + Leave + + Delete family + Delete this family? + Everyone loses access to what the family shares. Each person keeps the lists, books, and recipes they created. + Delete + + Cancel + That didn’t go through. Check your connection and try again. + diff --git a/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyBloc.kt b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyBloc.kt new file mode 100644 index 000000000..772bd445f --- /dev/null +++ b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyBloc.kt @@ -0,0 +1,142 @@ +package com.plusmobileapps.chefmate.family.core + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.plusmobileapps.chefmate.BlocContext +import com.plusmobileapps.chefmate.Consumer +import com.plusmobileapps.chefmate.family.core.ui.FamilyScreen +import com.plusmobileapps.chefmate.family.data.Family +import com.plusmobileapps.chefmate.family.data.FamilyMember +import com.plusmobileapps.chefmate.text.TextData +import com.plusmobileapps.chefmate.ui.ComposeScreen +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.StateFlow + +/** + * The single screen behind the More tab's Family row. Renders one of three states depending on + * [Model]: signed out, not in a family (create one), or in a family (members, invites, danger + * zone). + */ +interface FamilyBloc : ComposeScreen { + val state: StateFlow + + @Composable + override fun Content(modifier: Modifier) { + FamilyScreen(bloc = this, modifier = modifier) + } + + fun onBack() + + fun onSignInClicked() + + fun onSignUpClicked() + + // --- Creating a family --- + + fun onNewFamilyNameChanged(name: String) + + fun onCreateFamilyClicked() + + // --- Renaming (owner only) --- + + /** Owner tapped the edit affordance — seeds and shows the inline rename field. */ + fun onRenameClicked() + + fun onEditingNameChanged(name: String) + + fun onRenameConfirmed() + + fun onRenameCancelled() + + // --- Inviting (owner only) --- + + fun onInviteEmailChanged(email: String) + + fun onInviteClicked() + + // --- Removing a member (owner only) --- + + /** Owner tapped remove on a member — asks for confirmation first. */ + fun onRemoveMemberClicked(memberId: String) + + fun onConfirmRemoveMember() + + fun onDismissRemoveMember() + + // --- Leaving / deleting --- + + /** Member tapped "Leave family" — asks for confirmation first. */ + fun onLeaveFamilyClicked() + + /** Owner tapped "Delete family" — asks for confirmation first. */ + fun onDeleteFamilyClicked() + + fun onConfirmFamilyAction() + + fun onDismissFamilyAction() + + /** The destructive action awaiting confirmation in [Model.pendingFamilyAction]. */ + enum class FamilyAction { + /** The owner is deleting the family for everyone. */ + DELETE, + /** A member is removing themselves from the family. */ + LEAVE, + } + + data class Model( + val isLoading: Boolean = true, + /** + * False when there's no real (non-anonymous) session. A family is tied to account emails, + * so a signed-out user sees a sign-in prompt instead of the create form. + */ + val isSignedIn: Boolean = true, + /** Null when the user isn't in a family — the screen shows the create form instead. */ + val family: Family? = null, + val members: ImmutableList = persistentListOf(), + /** True when the user owns the family, gating invite/rename/remove/delete. */ + val isOwner: Boolean = false, + val newFamilyName: String = "", + val isCreating: Boolean = false, + val createError: TextData? = null, + /** Non-null shows the inline rename field, holding the in-progress value. */ + val editingName: String? = null, + val isRenaming: Boolean = false, + val renameError: TextData? = null, + val inviteEmail: String = "", + val isInviting: Boolean = false, + val inviteError: TextData? = null, + /** The member awaiting remove confirmation; non-null shows the confirm dialog. */ + val removingMember: FamilyMember? = null, + /** Non-null shows the leave/delete confirmation dialog. */ + val pendingFamilyAction: FamilyAction? = null, + /** True while the leave or delete is in flight. */ + val isRemovingFamily: Boolean = false, + /** Set when the leave or delete failed, e.g. offline. */ + val familyActionError: TextData? = null, + ) { + val canCreate: Boolean + get() = newFamilyName.isNotBlank() && !isCreating + + val canInvite: Boolean + get() = inviteEmail.isNotBlank() && !isInviting + + val canConfirmRename: Boolean + get() = editingName?.isNotBlank() == true && !isRenaming + } + + sealed class Output { + /** Pop back to the More tab. */ + data object Back : Output() + + /** Signed-out user tapped Sign In on the prompt — open the auth flow. */ + data object OpenSignIn : Output() + + /** Signed-out user tapped Sign Up on the prompt — open the auth flow. */ + data object OpenSignUp : Output() + } + + fun interface Factory { + fun create(context: BlocContext, output: Consumer): FamilyBloc + } +} diff --git a/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyTestTags.kt b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyTestTags.kt new file mode 100644 index 000000000..278f42676 --- /dev/null +++ b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/FamilyTestTags.kt @@ -0,0 +1,26 @@ +package com.plusmobileapps.chefmate.family.core + +object FamilyTestTags { + const val SCREEN = "family_screen" + + const val SIGN_IN_BUTTON = "family_sign_in_button" + const val SIGN_UP_BUTTON = "family_sign_up_button" + + const val CREATE_NAME_FIELD = "family_create_name_field" + const val CREATE_BUTTON = "family_create_button" + + const val NAME = "family_name" + const val RENAME_BUTTON = "family_rename_button" + const val RENAME_FIELD = "family_rename_field" + const val RENAME_CONFIRM_BUTTON = "family_rename_confirm_button" + + const val MEMBERS = "family_members" + const val INVITE_EMAIL_FIELD = "family_invite_email_field" + const val INVITE_BUTTON = "family_invite_button" + + const val LEAVE_BUTTON = "family_leave_button" + const val DELETE_BUTTON = "family_delete_button" + + /** Per-row tag, suffixed with the member's remote id. */ + const val REMOVE_MEMBER_PREFIX = "family_remove_member_" +} diff --git a/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyScreen.kt b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyScreen.kt new file mode 100644 index 000000000..8a447c11a --- /dev/null +++ b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyScreen.kt @@ -0,0 +1,444 @@ +package com.plusmobileapps.chefmate.family.core.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chefmate.client.family.core.public.generated.resources.Res +import chefmate.client.family.core.public.generated.resources.family_action_cancel +import chefmate.client.family.core.public.generated.resources.family_create_button +import chefmate.client.family.core.public.generated.resources.family_delete_button +import chefmate.client.family.core.public.generated.resources.family_delete_confirm +import chefmate.client.family.core.public.generated.resources.family_delete_message +import chefmate.client.family.core.public.generated.resources.family_delete_title +import chefmate.client.family.core.public.generated.resources.family_empty_message +import chefmate.client.family.core.public.generated.resources.family_empty_title +import chefmate.client.family.core.public.generated.resources.family_group_members +import chefmate.client.family.core.public.generated.resources.family_group_owner +import chefmate.client.family.core.public.generated.resources.family_invite_button +import chefmate.client.family.core.public.generated.resources.family_invite_email_label +import chefmate.client.family.core.public.generated.resources.family_invite_hint +import chefmate.client.family.core.public.generated.resources.family_leave_button +import chefmate.client.family.core.public.generated.resources.family_leave_confirm +import chefmate.client.family.core.public.generated.resources.family_leave_message +import chefmate.client.family.core.public.generated.resources.family_leave_title +import chefmate.client.family.core.public.generated.resources.family_member_declined +import chefmate.client.family.core.public.generated.resources.family_member_pending +import chefmate.client.family.core.public.generated.resources.family_name_label +import chefmate.client.family.core.public.generated.resources.family_remove_cancel +import chefmate.client.family.core.public.generated.resources.family_remove_confirm +import chefmate.client.family.core.public.generated.resources.family_remove_member +import chefmate.client.family.core.public.generated.resources.family_remove_message +import chefmate.client.family.core.public.generated.resources.family_remove_title +import chefmate.client.family.core.public.generated.resources.family_rename +import chefmate.client.family.core.public.generated.resources.family_rename_cancel +import chefmate.client.family.core.public.generated.resources.family_rename_confirm +import chefmate.client.family.core.public.generated.resources.family_signed_out +import chefmate.client.family.core.public.generated.resources.family_signed_out_title +import chefmate.client.family.core.public.generated.resources.family_title +import com.plusmobileapps.chefmate.family.core.FamilyBloc +import com.plusmobileapps.chefmate.family.core.FamilyTestTags +import com.plusmobileapps.chefmate.family.data.FamilyMember +import com.plusmobileapps.chefmate.family.data.FamilyMemberStatus +import com.plusmobileapps.chefmate.text.FixedString +import com.plusmobileapps.chefmate.text.PhraseModel +import com.plusmobileapps.chefmate.text.asTextData +import com.plusmobileapps.chefmate.ui.components.PlusAvatar +import com.plusmobileapps.chefmate.ui.components.PlusButton +import com.plusmobileapps.chefmate.ui.components.PlusButtonVariant +import com.plusmobileapps.chefmate.ui.components.PlusDialog +import com.plusmobileapps.chefmate.ui.components.PlusHeaderContainer +import com.plusmobileapps.chefmate.ui.components.PlusHeaderData +import com.plusmobileapps.chefmate.ui.components.PlusTextField +import com.plusmobileapps.chefmate.ui.components.SignedOutPrompt +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun FamilyScreen(bloc: FamilyBloc, modifier: Modifier = Modifier) { + val model by bloc.state.collectAsState() + + ConfirmationDialogs(bloc = bloc, model = model) + + PlusHeaderContainer( + modifier = modifier.testTag(FamilyTestTags.SCREEN).imePadding(), + data = + PlusHeaderData.Child( + title = Res.string.family_title.asTextData(), + onBackClick = bloc::onBack, + ), + contentPadding = PaddingValues(ChefMateTheme.dimens.paddingNormal), + ) { + when { + model.isLoading -> Unit + !model.isSignedIn -> + SignedOutPrompt( + title = Res.string.family_signed_out_title.asTextData(), + message = Res.string.family_signed_out.asTextData(), + onSignInClick = bloc::onSignInClicked, + onSignUpClick = bloc::onSignUpClicked, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.fillMaxWidth().padding(top = ChefMateTheme.dimens.paddingLarge), + signInButtonModifier = Modifier.testTag(FamilyTestTags.SIGN_IN_BUTTON), + signUpButtonModifier = Modifier.testTag(FamilyTestTags.SIGN_UP_BUTTON), + ) + model.family == null -> CreateFamilySection(bloc = bloc, model = model) + else -> FamilyDetails(bloc = bloc, model = model) + } + } +} + +@Composable +private fun CreateFamilySection(bloc: FamilyBloc, model: FamilyBloc.Model) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = ChefMateTheme.dimens.paddingLarge), + verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.family_empty_title), + style = ChefMateTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(Res.string.family_empty_message), + style = ChefMateTheme.typography.bodyMedium, + color = ChefMateTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + PlusTextField( + value = model.newFamilyName, + onValueChange = bloc::onNewFamilyNameChanged, + modifier = + Modifier.fillMaxWidth() + .padding(top = ChefMateTheme.dimens.paddingNormal) + .testTag(FamilyTestTags.CREATE_NAME_FIELD), + label = { Text(stringResource(Res.string.family_name_label)) }, + singleLine = true, + error = model.createError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { bloc.onCreateFamilyClicked() }), + ) + PlusButton( + text = Res.string.family_create_button.asTextData(), + isLoading = model.isCreating, + enabled = model.canCreate, + onClick = bloc::onCreateFamilyClicked, + modifier = Modifier.fillMaxWidth().testTag(FamilyTestTags.CREATE_BUTTON), + ) + } +} + +@Composable +private fun FamilyDetails(bloc: FamilyBloc, model: FamilyBloc.Model) { + val family = model.family ?: return + + if (model.editingName != null) { + RenameField(bloc = bloc, model = model, value = model.editingName!!) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + ) { + Text( + text = family.name, + style = ChefMateTheme.typography.headlineSmall, + modifier = Modifier.weight(1f).testTag(FamilyTestTags.NAME), + ) + if (model.isOwner) { + IconButton( + onClick = bloc::onRenameClicked, + modifier = Modifier.testTag(FamilyTestTags.RENAME_BUTTON), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(Res.string.family_rename), + modifier = Modifier.size(20.dp), + ) + } + } + } + } + + MembersSection(bloc = bloc, model = model) + + DangerZone(bloc = bloc, model = model) +} + +@Composable +private fun RenameField(bloc: FamilyBloc, model: FamilyBloc.Model, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall)) { + PlusTextField( + value = value, + onValueChange = bloc::onEditingNameChanged, + modifier = Modifier.fillMaxWidth().testTag(FamilyTestTags.RENAME_FIELD), + label = { Text(stringResource(Res.string.family_name_label)) }, + singleLine = true, + error = model.renameError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { bloc.onRenameConfirmed() }), + ) + Row(horizontalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall)) { + PlusButton( + text = Res.string.family_rename_cancel.asTextData(), + variant = PlusButtonVariant.SECONDARY, + onClick = bloc::onRenameCancelled, + modifier = Modifier.weight(1f), + ) + PlusButton( + text = Res.string.family_rename_confirm.asTextData(), + isLoading = model.isRenaming, + enabled = model.canConfirmRename, + onClick = bloc::onRenameConfirmed, + modifier = Modifier.weight(1f).testTag(FamilyTestTags.RENAME_CONFIRM_BUTTON), + ) + } + } +} + +@Composable +private fun MembersSection(bloc: FamilyBloc, model: FamilyBloc.Model) { + val focusManager = LocalFocusManager.current + Column( + modifier = + Modifier.fillMaxWidth() + .padding(top = ChefMateTheme.dimens.paddingLarge) + .testTag(FamilyTestTags.MEMBERS), + verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + ) { + HorizontalDivider() + + // Only two groups, because a family has only two roles — the owner administers it and + // everyone else has equal edit rights. Pending and declined invites sit in Members, dimmed. + // No section header above these: with just "Owner" and "Members" it would only repeat the + // group headings, unlike the recipe-book screen's three roles under "Collaborators". + MemberGroup( + title = Res.string.family_group_owner, + members = model.members.filter { it.isOwner }, + canManage = model.isOwner, + onRemove = bloc::onRemoveMemberClicked, + ) + MemberGroup( + title = Res.string.family_group_members, + members = model.members.filterNot { it.isOwner }, + canManage = model.isOwner, + onRemove = bloc::onRemoveMemberClicked, + ) + + // Invite controls are owner-only; members just see the list above. + if (model.isOwner) { + PlusTextField( + value = model.inviteEmail, + onValueChange = bloc::onInviteEmailChanged, + modifier = + Modifier.fillMaxWidth() + .padding(top = ChefMateTheme.dimens.paddingSmall) + .testTag(FamilyTestTags.INVITE_EMAIL_FIELD), + label = { Text(stringResource(Res.string.family_invite_email_label)) }, + singleLine = true, + error = model.inviteError, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Email, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Send, + ), + keyboardActions = + KeyboardActions( + onSend = { + bloc.onInviteClicked() + focusManager.clearFocus() + } + ), + ) + Text( + text = stringResource(Res.string.family_invite_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PlusButton( + text = Res.string.family_invite_button.asTextData(), + variant = PlusButtonVariant.SECONDARY, + isLoading = model.isInviting, + enabled = model.canInvite, + onClick = bloc::onInviteClicked, + modifier = + Modifier.fillMaxWidth() + .padding(top = ChefMateTheme.dimens.paddingSmall) + .testTag(FamilyTestTags.INVITE_BUTTON), + ) + } + } +} + +@Composable +private fun DangerZone(bloc: FamilyBloc, model: FamilyBloc.Model) { + // The owner deletes the family for everyone; anyone else removes only themselves. The two are + // mutually exclusive, so only one button ever shows. + val isDelete = model.isOwner + Column( + modifier = Modifier.fillMaxWidth().padding(top = ChefMateTheme.dimens.paddingLarge), + verticalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + ) { + HorizontalDivider() + PlusButton( + text = + if (isDelete) Res.string.family_delete_button.asTextData() + else Res.string.family_leave_button.asTextData(), + variant = PlusButtonVariant.DESTRUCTIVE, + isLoading = model.isRemovingFamily, + onClick = if (isDelete) bloc::onDeleteFamilyClicked else bloc::onLeaveFamilyClicked, + modifier = + Modifier.fillMaxWidth() + .padding(top = ChefMateTheme.dimens.paddingSmall) + .testTag( + if (isDelete) FamilyTestTags.DELETE_BUTTON else FamilyTestTags.LEAVE_BUTTON + ), + ) + model.familyActionError?.let { error -> + Text( + text = error.localized(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun ConfirmationDialogs(bloc: FamilyBloc, model: FamilyBloc.Model) { + model.removingMember?.let { member -> + PlusDialog( + title = Res.string.family_remove_title.asTextData(), + message = + PhraseModel( + resource = Res.string.family_remove_message, + "email" to FixedString(member.email), + ), + confirmButtonText = Res.string.family_remove_confirm.asTextData(), + dismissButtonText = Res.string.family_remove_cancel.asTextData(), + onConfirmClick = bloc::onConfirmRemoveMember, + onDismissRequest = bloc::onDismissRemoveMember, + ) + } + + model.pendingFamilyAction?.let { action -> + val isDelete = action == FamilyBloc.FamilyAction.DELETE + PlusDialog( + title = + if (isDelete) Res.string.family_delete_title.asTextData() + else Res.string.family_leave_title.asTextData(), + message = + if (isDelete) Res.string.family_delete_message.asTextData() + else Res.string.family_leave_message.asTextData(), + confirmButtonText = + if (isDelete) Res.string.family_delete_confirm.asTextData() + else Res.string.family_leave_confirm.asTextData(), + dismissButtonText = Res.string.family_action_cancel.asTextData(), + onConfirmClick = bloc::onConfirmFamilyAction, + onDismissRequest = bloc::onDismissFamilyAction, + ) + } +} + +@Composable +private fun MemberGroup( + title: StringResource, + members: List, + canManage: Boolean, + onRemove: (String) -> Unit, +) { + if (members.isEmpty()) return + Text( + text = stringResource(title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = ChefMateTheme.dimens.paddingSmall), + ) + members.forEach { member -> + MemberRow(member = member, canManage = canManage, onRemove = onRemove) + } +} + +@Composable +private fun MemberRow(member: FamilyMember, canManage: Boolean, onRemove: (String) -> Unit) { + // name → email, with pending/declined invites dimmed and tagged. Pending invites have no + // account + // yet, so they fall back to the email as the primary line. A declined invite stays visible so + // the owner can see it was turned down, and can then remove or re-invite. + val name = member.name?.takeIf { it.isNotBlank() } + val pending = member.status == FamilyMemberStatus.PENDING + val declined = member.status == FamilyMemberStatus.REJECTED + val secondary = + listOfNotNull( + member.email.takeIf { name != null }, + stringResource(Res.string.family_member_pending).takeIf { pending }, + stringResource(Res.string.family_member_declined).takeIf { declined }, + ) + .joinToString(" · ") + Row( + modifier = Modifier.fillMaxWidth().alpha(if (pending || declined) 0.5f else 1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ChefMateTheme.dimens.paddingSmall), + ) { + PlusAvatar( + imageUrl = member.avatarUrl, + contentDescription = null, + fallbackText = name ?: member.email, + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = name ?: member.email, style = MaterialTheme.typography.bodyMedium) + if (secondary.isNotEmpty()) { + Text( + text = secondary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + val memberId = member.id + if (canManage && !member.isOwner && memberId != null) { + IconButton( + onClick = { onRemove(memberId) }, + modifier = Modifier.testTag(FamilyTestTags.REMOVE_MEMBER_PREFIX + memberId), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = + stringResource(Res.string.family_remove_member, member.email), + modifier = Modifier.size(18.dp), + ) + } + } + } +} diff --git a/client/family/data/impl/build.gradle.kts b/client/family/data/impl/build.gradle.kts new file mode 100644 index 000000000..2a171f54a --- /dev/null +++ b/client/family/data/impl/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.client.family.data.public) + implementation(projects.client.shared) + implementation(projects.client.database.core) + api(projects.client.util.public) + implementation(projects.client.auth.data.public) + implementation(libs.supabase.client) + implementation(libs.supabase.postgrest) + implementation(libs.supabase.realtime) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(projects.client.auth.data.testing) + implementation(projects.client.family.data.testing) + implementation(projects.client.util.testing) + } + jvmMain.dependencies { implementation(libs.ktor.client.cio) } + androidMain.dependencies { implementation(libs.ktor.client.cio) } + iosMain.dependencies { implementation(libs.ktor.client.darwin) } + } +} + +plusLibrary { + namespace = "com.plusmobileapps.chefmate.family.data.impl" + enableDi = true + enableTesting = true + enableDatabaseTesting = true +} diff --git a/client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImpl.kt b/client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImpl.kt new file mode 100644 index 000000000..4dd77fb90 --- /dev/null +++ b/client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImpl.kt @@ -0,0 +1,339 @@ +package com.plusmobileapps.chefmate.family.data.impl + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import app.cash.sqldelight.coroutines.mapToOneOrNull +import com.plusmobileapps.chefmate.auth.data.AuthState +import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository +import com.plusmobileapps.chefmate.auth.data.ChefMateUser +import com.plusmobileapps.chefmate.database.FamilyMemberQueries +import com.plusmobileapps.chefmate.database.FamilyQueries +import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.di.IO +import com.plusmobileapps.chefmate.family.data.AlreadyInFamilyException +import com.plusmobileapps.chefmate.family.data.Family +import com.plusmobileapps.chefmate.family.data.FamilyInvite +import com.plusmobileapps.chefmate.family.data.FamilyMember +import com.plusmobileapps.chefmate.family.data.FamilyMemberStatus +import com.plusmobileapps.chefmate.family.data.FamilyRepository +import com.plusmobileapps.chefmate.family.data.FamilyRole +import com.plusmobileapps.chefmate.family.data.remote.FamilyRemoteDataSource +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyCollaborator +import com.plusmobileapps.chefmate.util.DateTimeUtil +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Reads are served from the local cache so the Family screen renders offline; writes go + * remote-first and throw, because "one family per user" can only be arbitrated by the database. See + * [FamilyRepository] and the comments in `Family.sq`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Inject +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class FamilyRepositoryImpl( + private val familyQueries: FamilyQueries, + private val memberQueries: FamilyMemberQueries, + @IO private val ioContext: CoroutineContext, + private val dateTimeUtil: DateTimeUtil, + private val remote: FamilyRemoteDataSource, + private val authRepository: AuthenticationRepository, +) : FamilyRepository { + + private val scope = CoroutineScope(ioContext + SupervisorJob()) + private val syncMutex = Mutex() + + private val pendingInvitesState = MutableStateFlow>(emptyList()) + + private var realtimeJob: Job? = null + private var realtimeUserId: String? = null + + private val currentUser: ChefMateUser? + get() = (authRepository.state.value as? AuthState.Authenticated)?.user + + private val cachedFamily: Flow = + familyQueries.getCurrent().asFlow().mapToOneOrNull(ioContext) + + override val family: StateFlow = + combine(cachedFamily, authRepository.state) { row, authState -> + val userId = (authState as? AuthState.Authenticated)?.user?.userId + row?.let { + Family( + id = it.id, + remoteId = it.remoteId, + name = it.name, + // ownerId is null only for rows written before the owner was known; treat + // that as "not owner" so admin actions stay hidden rather than failing + // against RLS. + isOwnedByCurrentUser = userId != null && it.ownerId == userId, + ) + } + } + .stateIn(scope, SharingStarted.Eagerly, null) + + init { + scope.launch { + authRepository.state.collect { state -> + if (state is AuthState.Authenticated) { + syncWithRemote() + startRealtimeSync(state.user.userId) + } else { + stopRealtimeSync() + clearLocalData() + } + } + } + } + + /** + * Subscribes to remote family changes so an invite accepted, a member removed, or a rename done + * on another device lands here without waiting for the next sign-in. Emissions are debounced to + * coalesce bursts and each one re-runs the full reconcile. + */ + @OptIn(FlowPreview::class) + private fun startRealtimeSync(userId: String) { + if (realtimeUserId == userId && realtimeJob?.isActive == true) return + stopRealtimeSync() + realtimeUserId = userId + realtimeJob = scope.launch { + remote + .observeChanges() + .debounce(REALTIME_DEBOUNCE_MS) + .retry { cause -> + // Keep the subscription alive across transient failures, but let structured + // cancellation (sign-out / scope teardown) stop the loop. + if (cause is CancellationException) throw cause + delay(REALTIME_RETRY_DELAY_MS) + true + } + .catch {} + .collect { syncWithRemote() } + } + } + + private fun stopRealtimeSync() { + realtimeJob?.cancel() + realtimeJob = null + realtimeUserId = null + } + + override fun members(): Flow> = cachedFamily.flatMapLatest { row -> + if (row == null) { + flowOf(emptyList()) + } else { + memberQueries.getByFamilyId(row.id).asFlow().mapToList(ioContext).map { members -> + members.map { member -> + FamilyMember( + id = member.remoteId, + email = member.userEmail, + role = FamilyRole.fromWire(member.role), + status = FamilyMemberStatus.fromWire(member.status), + name = member.displayName, + isOwner = member.isOwner != 0L, + avatarUrl = member.avatarUrl, + ) + } + } + } + } + + override fun pendingInvites(): Flow> = pendingInvitesState + + override suspend fun createFamily(name: String) { + val user = requireUser() + // Cheap local guard so the common case fails fast with a clear error; the database's + // partial + // unique index is still the real enforcement, caught below for the racy case. + if (family.value != null) throw AlreadyInFamilyException() + translatingConflict { remote.createFamily(name.trim(), user.userId) } + syncWithRemote() + } + + override suspend fun renameFamily(name: String) { + val remoteId = requireFamily().remoteId + remote.renameFamily(remoteId, name.trim()) + syncWithRemote() + } + + override suspend fun invite(email: String) { + val user = requireUser() + val remoteId = requireFamily().remoteId + // Normalise the address so it always matches the invitee's (lowercased) account email. + remote.invite( + familyRemoteId = remoteId, + email = email.trim().lowercase(), + invitedBy = user.userId, + ) + syncWithRemote() + } + + override suspend fun removeMember(memberId: String) { + remote.deleteMember(memberId) + syncWithRemote() + } + + override suspend fun leaveFamily() { + val user = requireUser() + val remoteId = requireFamily().remoteId + remote.leaveFamily(familyRemoteId = remoteId, userId = user.userId) + // Access is gone the moment the member row is deleted, so drop the local copy rather than + // waiting for the next reconcile to notice it's unreadable. + clearCache() + syncWithRemote() + } + + override suspend fun deleteFamily() { + val remoteId = requireFamily().remoteId + remote.deleteFamily(remoteId) + clearCache() + syncWithRemote() + } + + override suspend fun acceptInvite(memberId: String) { + val user = requireUser() + if (family.value != null) throw AlreadyInFamilyException() + translatingConflict { remote.acceptInvite(memberId = memberId, userId = user.userId) } + syncWithRemote() + } + + override suspend fun declineInvite(memberId: String) { + // Mark the invite rejected rather than deleting the row, so the owner can see it was turned + // down. Access requires status = 'accepted' either way. + remote.rejectInvite(memberId) + syncWithRemote() + } + + override suspend fun refresh() { + syncWithRemote() + } + + override suspend fun clearLocalData() { + clearCache() + pendingInvitesState.value = emptyList() + } + + private suspend fun clearCache() = + withContext(ioContext) { + // Delete members explicitly rather than relying on the FK cascade — foreign-key + // enforcement is a per-connection pragma and isn't guaranteed on every driver. + memberQueries.deleteAll() + familyQueries.deleteAll() + } + + /** + * Reconciles the local cache with the server: the caller's family (or its absence), its member + * list, and any invites addressed to them. Swallows failures — the cache simply stays as it was + * until the next realtime emission or manual refresh. + */ + private suspend fun syncWithRemote() = syncMutex.withLock { + if (currentUser == null) return@withLock + try { + val remoteFamily = remote.fetchCurrentFamily() + val remoteFamilyId = remoteFamily?.id + + if (remoteFamily == null || remoteFamilyId == null) { + // Not in a family — either never joined, or removed / left on another device. + clearCache() + } else { + val now = dateTimeUtil.now.toString() + withContext(ioContext) { + familyQueries.upsert( + remoteId = remoteFamilyId, + name = remoteFamily.name, + ownerId = remoteFamily.ownerId, + createdAt = remoteFamily.createdAt ?: now, + updatedAt = remoteFamily.updatedAt ?: now, + ) + } + val members = remote.fetchMembers(remoteFamilyId) + withContext(ioContext) { + val localId = + familyQueries.getByRemoteId(remoteFamilyId).executeAsOneOrNull()?.id + if (localId != null) { + // The RPC returns the full membership every time, so replace wholesale + // rather than diffing — removals then can't linger in the cache. + memberQueries.transaction { + memberQueries.deleteByFamilyId(localId) + members.forEach { memberQueries.insert(it, localId) } + } + } + } + } + + pendingInvitesState.value = + remote.fetchPendingInvites().map { + FamilyInvite(memberId = it.memberId, familyName = it.familyName) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) {} + } + + private fun FamilyMemberQueries.insert(member: RemoteFamilyCollaborator, familyLocalId: Long) = + insert( + familyLocalId = familyLocalId, + remoteId = member.memberId, + userId = null, + userEmail = member.email, + role = member.role, + status = member.status, + isOwner = if (member.isOwner) 1L else 0L, + displayName = member.name, + avatarUrl = member.avatarUrl, + ) + + private fun requireUser(): ChefMateUser = currentUser ?: error("Not signed in") + + private fun requireFamily(): Family = family.value ?: error("Not in a family") + + /** + * Runs [block], rethrowing a unique-constraint violation on the "one accepted family per user" + * index as [AlreadyInFamilyException] so the UI can tell the user to leave their current family + * rather than showing a raw Postgres error. Postgres reports it as SQLSTATE 23505. + */ + private suspend fun translatingConflict(block: suspend () -> T): T = + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val message = e.message.orEmpty() + if (message.contains(ONE_FAMILY_INDEX) || message.contains(UNIQUE_VIOLATION)) { + throw AlreadyInFamilyException() + } + throw e + } + + private companion object { + const val REALTIME_DEBOUNCE_MS = 300L + const val REALTIME_RETRY_DELAY_MS = 5_000L + const val ONE_FAMILY_INDEX = "idx_fm_one_accepted_family_per_user" + const val UNIQUE_VIOLATION = "23505" + } +} diff --git a/client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/remote/SupabaseFamilyRemoteDataSource.kt b/client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/remote/SupabaseFamilyRemoteDataSource.kt new file mode 100644 index 000000000..ab69c9c5f --- /dev/null +++ b/client/family/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/impl/remote/SupabaseFamilyRemoteDataSource.kt @@ -0,0 +1,135 @@ +package com.plusmobileapps.chefmate.family.data.impl.remote + +import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.family.data.remote.FamilyRemoteDataSource +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamily +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyCollaborator +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyInvite +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyMember +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.postgrest.from +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.realtime.PostgresAction +import io.github.jan.supabase.realtime.channel +import io.github.jan.supabase.realtime.postgresChangeFlow +import io.github.jan.supabase.realtime.realtime +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +@Inject +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class SupabaseFamilyRemoteDataSource(private val supabaseClient: SupabaseClient) : + FamilyRemoteDataSource { + + override fun observeChanges(): Flow { + val channel = supabaseClient.channel(REALTIME_CHANNEL) + // Register a Postgres-change binding per table before subscribing — the SDK requires + // bindings to exist when the channel joins. Row-level security scopes each stream to rows + // this user can see, so we simply re-reconcile on any emission. + val tableChanges = REALTIME_TABLES.map { table -> + channel.postgresChangeFlow(schema = "public") { this.table = table } + } + return merge(*tableChanges.toTypedArray()) + .map {} + .onStart { channel.subscribe() } + .onCompletion { + // Runs on cancellation too (e.g. sign-out). Force the leave through even though the + // collecting coroutine is being cancelled, so the server-side channel is released + // before a same-named channel is recreated on the next sign-in. + withContext(NonCancellable) { supabaseClient.realtime.removeChannel(channel) } + } + } + + override suspend fun fetchCurrentFamily(): RemoteFamily? = + supabaseClient.postgrest.rpc("current_family").decodeList().firstOrNull() + + override suspend fun fetchMembers(familyRemoteId: String): List = + supabaseClient.postgrest + .rpc( + "family_members_with_profiles", + buildJsonObject { put("p_family_id", familyRemoteId) }, + ) + .decodeList() + + override suspend fun fetchPendingInvites(): List = + supabaseClient.postgrest.rpc("family_pending_invites").decodeList() + + override suspend fun createFamily(name: String, ownerId: String): RemoteFamily = + supabaseClient + .from("families") + .insert(RemoteFamily(name = name, ownerId = ownerId)) { select() } + .decodeSingle() + + override suspend fun renameFamily(familyRemoteId: String, name: String) { + supabaseClient.from("families").update(JsonObject(mapOf("name" to JsonPrimitive(name)))) { + filter { eq("id", familyRemoteId) } + } + } + + override suspend fun deleteFamily(familyRemoteId: String) { + supabaseClient.from("families").delete { filter { eq("id", familyRemoteId) } } + } + + override suspend fun invite(familyRemoteId: String, email: String, invitedBy: String) { + supabaseClient + .from("family_members") + .insert( + RemoteFamilyMember( + familyId = familyRemoteId, + invitedEmail = email, + invitedBy = invitedBy, + role = "member", + status = "pending", + ) + ) + } + + override suspend fun deleteMember(memberId: String) { + supabaseClient.from("family_members").delete { filter { eq("id", memberId) } } + } + + override suspend fun leaveFamily(familyRemoteId: String, userId: String) { + supabaseClient.from("family_members").delete { + filter { + eq("family_id", familyRemoteId) + eq("user_id", userId) + } + } + } + + override suspend fun acceptInvite(memberId: String, userId: String) { + supabaseClient.from("family_members").update( + JsonObject( + mapOf("user_id" to JsonPrimitive(userId), "status" to JsonPrimitive("accepted")) + ) + ) { + filter { eq("id", memberId) } + } + } + + override suspend fun rejectInvite(memberId: String) { + supabaseClient.from("family_members").update( + JsonObject(mapOf("status" to JsonPrimitive("rejected"))) + ) { + filter { eq("id", memberId) } + } + } + + private companion object { + const val REALTIME_CHANNEL = "family-sync" + val REALTIME_TABLES = listOf("families", "family_members") + } +} diff --git a/client/family/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImplTest.kt b/client/family/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImplTest.kt new file mode 100644 index 000000000..cd1e7d897 --- /dev/null +++ b/client/family/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/family/data/impl/FamilyRepositoryImplTest.kt @@ -0,0 +1,238 @@ +@file:Suppress("FunctionName") +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.plusmobileapps.chefmate.family.data.impl + +import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository +import com.plusmobileapps.chefmate.database.Database +import com.plusmobileapps.chefmate.database.testing.createTestDatabase +import com.plusmobileapps.chefmate.family.data.AlreadyInFamilyException +import com.plusmobileapps.chefmate.family.data.FamilyMemberStatus +import com.plusmobileapps.chefmate.family.data.FamilyRole +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamily +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyCollaborator +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyInvite +import com.plusmobileapps.chefmate.family.data.testing.FakeFamilyRemoteDataSource +import com.plusmobileapps.chefmate.util.testing.FakeDateTimeUtil +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest + +class FamilyRepositoryImplTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val db: Database = createTestDatabase() + private val fakeAuth = FakeAuthenticationRepository() + private val remote = FakeFamilyRemoteDataSource() + + private fun repository() = + FamilyRepositoryImpl( + familyQueries = db.familyQueries, + memberQueries = db.familyMemberQueries, + ioContext = testDispatcher, + dateTimeUtil = FakeDateTimeUtil(), + remote = remote, + authRepository = fakeAuth, + ) + + @Test + fun When_signed_out_Then_there_is_no_family() = + runTest(testDispatcher) { + val repo = repository() + + repo.family.value shouldBe null + repo.members().first() shouldBe emptyList() + } + + @Test + fun When_creating_a_family_Then_it_is_cached_and_owned_by_the_current_user() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val userId = currentUserId() + val repo = repository() + + repo.createFamily(" The Hendersons ") + + val family = repo.family.value + family shouldNotBe null + // The name is trimmed before it reaches the server. + family!!.name shouldBe "The Hendersons" + family.remoteId shouldBe "family-remote-1" + // The fake stamps the caller as owner, so the local row resolves to owned-by-me. + remote.currentFamily?.ownerId shouldBe userId + } + + @Test + fun When_already_in_a_family_Then_creating_another_is_rejected() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + repo.createFamily("The Hendersons") + + shouldThrow { repo.createFamily("Second Family") } + } + + @Test + fun When_already_in_a_family_Then_accepting_an_invite_is_rejected() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + repo.createFamily("The Hendersons") + remote.pendingInvites += + RemoteFamilyInvite( + memberId = "m1", + familyId = "other-family", + familyName = "The Other Family", + role = "member", + status = "pending", + ) + + shouldThrow { repo.acceptInvite("m1") } + } + + @Test + fun When_syncing_Then_members_are_replaced_wholesale_so_removals_do_not_linger() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + remote.currentFamily = + RemoteFamily(id = "family-remote-1", name = "The Hendersons", ownerId = "test-id") + remote.members = + mutableListOf( + ownerRow(), + memberRow(id = "m1", email = "alex@example.com"), + memberRow(id = "m2", email = "sam@example.com"), + ) + + repo.refresh() + repo.members().first().map { it.email } shouldBe + listOf("owner@example.com", "alex@example.com", "sam@example.com") + + // Someone removed m2 on another device. + remote.members = + mutableListOf(ownerRow(), memberRow(id = "m1", email = "alex@example.com")) + repo.refresh() + + repo.members().first().map { it.email } shouldBe + listOf("owner@example.com", "alex@example.com") + } + + @Test + fun When_the_server_reports_no_family_Then_the_local_cache_is_dropped() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + repo.createFamily("The Hendersons") + repo.family.value shouldNotBe null + + // Removed from the family on another device. + remote.currentFamily = null + repo.refresh() + + repo.family.value shouldBe null + repo.members().first() shouldBe emptyList() + } + + @Test + fun When_inviting_Then_the_address_is_normalised_before_it_is_sent() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + repo.createFamily("The Hendersons") + + repo.invite(" Alex@Example.COM ") + + remote.invitedEmails shouldBe listOf("alex@example.com") + } + + @Test + fun When_a_member_is_pending_Then_the_status_and_role_survive_the_round_trip() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + remote.currentFamily = + RemoteFamily(id = "family-remote-1", name = "The Hendersons", ownerId = "test-id") + remote.members = mutableListOf(ownerRow(), memberRow(id = "m1", status = "pending")) + + repo.refresh() + + val member = repo.members().first().single { !it.isOwner } + member.status shouldBe FamilyMemberStatus.PENDING + member.role shouldBe FamilyRole.MEMBER + member.id shouldBe "m1" + } + + @Test + fun When_leaving_Then_the_local_cache_is_dropped_immediately() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + repo.createFamily("The Hendersons") + + repo.leaveFamily() + + remote.leftFamilyId shouldBe "family-remote-1" + repo.family.value shouldBe null + } + + @Test + fun When_signing_out_Then_cached_family_state_is_cleared() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + repo.createFamily("The Hendersons") + + repo.clearLocalData() + + repo.family.value shouldBe null + repo.members().first() shouldBe emptyList() + repo.pendingInvites().first() shouldBe emptyList() + } + + @Test + fun When_a_sync_fails_Then_the_previous_cache_is_left_intact() = + runTest(testDispatcher) { + fakeAuth.setAuthenticated() + val repo = repository() + repo.createFamily("The Hendersons") + + remote.errorToThrow = RuntimeException("offline") + repo.refresh() + + repo.family.value?.name shouldBe "The Hendersons" + } + + private fun currentUserId(): String = + (fakeAuth.state.value as com.plusmobileapps.chefmate.auth.data.AuthState.Authenticated) + .user + .userId + + private fun ownerRow() = + RemoteFamilyCollaborator( + memberId = null, + email = "owner@example.com", + name = "Owner", + role = "owner", + status = "accepted", + isOwner = true, + ) + + private fun memberRow( + id: String, + email: String = "member@example.com", + status: String = "accepted", + ) = + RemoteFamilyCollaborator( + memberId = id, + email = email, + name = null, + role = "member", + status = status, + isOwner = false, + ) +} diff --git a/client/family/data/public/build.gradle.kts b/client/family/data/public/build.gradle.kts new file mode 100644 index 000000000..0fcfceadf --- /dev/null +++ b/client/family/data/public/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.kmpLibrary) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.client.shared) + implementation(libs.kotlinx.serialization.json) + } + } +} + +plusLibrary { namespace = "com.plusmobileapps.chefmate.family.data" } diff --git a/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/Family.kt b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/Family.kt new file mode 100644 index 000000000..ef3277cff --- /dev/null +++ b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/Family.kt @@ -0,0 +1,28 @@ +package com.plusmobileapps.chefmate.family.data + +/** + * A group of accounts that share content across grocery lists, recipe books, and the meal plan. + * + * A user belongs to at most one family. That's enforced by the database (a partial unique index on + * accepted `family_members` rows), which is why the repository exposes a single nullable [Family] + * rather than a list plus an active selection. + */ +data class Family( + /** Device-local row id. */ + val id: Long, + /** Server id — the value future phases stamp onto grocery lists, recipe books, and meals. */ + val remoteId: String, + val name: String, + /** True when the signed-in user created the family, and so may invite, rename, and delete. */ + val isOwnedByCurrentUser: Boolean, +) { + companion object { + val Sample = + Family( + id = 1L, + remoteId = "family-1", + name = "The Hendersons", + isOwnedByCurrentUser = true, + ) + } +} diff --git a/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyMember.kt b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyMember.kt new file mode 100644 index 000000000..efbf9b68b --- /dev/null +++ b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyMember.kt @@ -0,0 +1,76 @@ +package com.plusmobileapps.chefmate.family.data + +/** Invite lifecycle for a [FamilyMember]: awaiting a response, accepted, or turned down. */ +enum class FamilyMemberStatus { + PENDING, + ACCEPTED, + REJECTED; + + /** The wire value stored in `family_members.status`. */ + val wireValue: String + get() = name.lowercase() + + companion object { + fun fromWire(value: String?): FamilyMemberStatus = + entries.firstOrNull { it.wireValue == value?.lowercase() } ?: PENDING + } +} + +/** + * Someone on a family: an accepted member, a pending email invite, or an invite the recipient + * declined (kept so the owner can see the outcome). + * + * [id] is the remote `family_members` row id. It is null for the owner, whose entry is synthesized + * server-side by the `family_members_with_profiles` RPC rather than read from a member row — so + * [isOwner], not [role], is what identifies them. + */ +data class FamilyMember( + val id: String?, + val email: String, + val role: FamilyRole, + val status: FamilyMemberStatus, + /** Display name from the user's profile; null for pending invites (no account yet). */ + val name: String? = null, + /** True for the synthesized entry representing the family owner. */ + val isOwner: Boolean = false, + /** Profile photo URL when known; null entries fall back to a lettered avatar. */ + val avatarUrl: String? = null, +) { + /** Convenience for the common "membership is live" check. */ + val accepted: Boolean + get() = status == FamilyMemberStatus.ACCEPTED + + companion object { + val SampleOwner = + FamilyMember( + id = null, + email = "jamie@example.com", + role = FamilyRole.OWNER, + status = FamilyMemberStatus.ACCEPTED, + name = "Jamie Henderson", + isOwner = true, + ) + + val SampleMember = + FamilyMember( + id = "member-2", + email = "alex@example.com", + role = FamilyRole.MEMBER, + status = FamilyMemberStatus.ACCEPTED, + name = "Alex Henderson", + ) + + val SamplePending = + FamilyMember( + id = "member-3", + email = "sam@example.com", + role = FamilyRole.MEMBER, + status = FamilyMemberStatus.PENDING, + ) + + val Samples = listOf(SampleOwner, SampleMember, SamplePending) + } +} + +/** A pending family invite addressed to the current user, surfaced in Notifications. */ +data class FamilyInvite(val memberId: String, val familyName: String) diff --git a/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRepository.kt b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRepository.kt new file mode 100644 index 000000000..6bdbcbe8f --- /dev/null +++ b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRepository.kt @@ -0,0 +1,81 @@ +package com.plusmobileapps.chefmate.family.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * The signed-in user's family and its membership. + * + * Reads ([family], [members], [pendingInvites]) are served from a local cache so the Family screen + * renders offline. Writes are **online operations** performed remote-first and throw on + * network/permission failure, because membership can only be arbitrated by the server — notably the + * "one family per user" rule, which the database enforces with a partial unique index. Callers + * surface the failure rather than queueing it. + */ +interface FamilyRepository { + /** + * The user's family, or null when they aren't in one (or are signed out). Later phases read + * this to decide whether a grocery list, recipe book, or meal can be shared with the family. + */ + val family: StateFlow + + /** + * Everyone on [family] — owner first, then accepted members, then invites. Empty with no + * family. + */ + fun members(): Flow> + + /** Family invites addressed to the current user's email and awaiting a response. */ + fun pendingInvites(): Flow> + + /** + * Creates a family named [name] with the current user as owner. + * + * @throws AlreadyInFamilyException if the user already belongs to one. + */ + suspend fun createFamily(name: String) + + /** Renames the family. Owner only. */ + suspend fun renameFamily(name: String) + + /** + * Invites [email] to the family. The invitee gets an email (sent by a database trigger) and an + * in-app notification; the invite stays pending until they accept. Owner only. + */ + suspend fun invite(email: String) + + /** Removes the member / cancels the invite with remote [memberId]. Owner only. */ + suspend fun removeMember(memberId: String) + + /** + * Removes the current user's own membership, then drops the family from the local cache. Owners + * can't leave their own family; they call [deleteFamily] instead. + */ + suspend fun leaveFamily() + + /** Deletes the family for everyone. Owner only. */ + suspend fun deleteFamily() + + /** + * Accepts the invite with remote [memberId] and pulls the family. + * + * @throws AlreadyInFamilyException if the user is already in a family — they have to leave it + * first, since a user can only belong to one. + */ + suspend fun acceptInvite(memberId: String) + + /** Declines the invite with remote [memberId]. */ + suspend fun declineInvite(memberId: String) + + /** Re-pulls the family, its members, and pending invites from the server. */ + suspend fun refresh() + + /** Drops all cached family state. Called on sign-out. */ + suspend fun clearLocalData() +} + +/** + * Thrown when an operation would put the user in a second family. A user can belong to at most one; + * the database rejects the write and the UI asks them to leave their current family first. + */ +class AlreadyInFamilyException(message: String = "Already in a family") : Exception(message) diff --git a/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRole.kt b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRole.kt new file mode 100644 index 000000000..b6edb68b7 --- /dev/null +++ b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/FamilyRole.kt @@ -0,0 +1,24 @@ +package com.plusmobileapps.chefmate.family.data + +/** + * A member's permission level within a family. + * + * Only two roles, unlike grocery lists' and recipe books' three. A family implies trust: every + * member can edit all family-scoped content, and the distinction that matters is who administers + * the group. + */ +enum class FamilyRole { + /** Created the family. Can invite, remove members, rename, and delete it. */ + OWNER, + /** Can read and edit everything shared with the family, but not administer the group. */ + MEMBER; + + /** The wire value stored in `family_members.role`. */ + val wireValue: String + get() = name.lowercase() + + companion object { + fun fromWire(value: String?): FamilyRole = + entries.firstOrNull { it.wireValue == value?.lowercase() } ?: MEMBER + } +} diff --git a/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/FamilyRemoteDataSource.kt b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/FamilyRemoteDataSource.kt new file mode 100644 index 000000000..03dec087f --- /dev/null +++ b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/FamilyRemoteDataSource.kt @@ -0,0 +1,53 @@ +package com.plusmobileapps.chefmate.family.data.remote + +import kotlinx.coroutines.flow.Flow + +/** Supabase access for families and their membership. Every method throws on failure. */ +interface FamilyRemoteDataSource { + /** + * Emits when a family or membership row the caller can see changes, so the repository can + * re-reconcile. Auto-reconnects are the caller's responsibility. + */ + fun observeChanges(): Flow + + /** + * The caller's family, or null when they aren't in one. + * + * Goes through the `current_family()` RPC rather than selecting from `families`: RLS + * deliberately lets a pending invitee read the family row they were invited to, so a plain + * select would pull an unjoined family into the local cache. + */ + suspend fun fetchCurrentFamily(): RemoteFamily? + + /** Everyone on [familyRemoteId], including the synthesized owner entry. */ + suspend fun fetchMembers(familyRemoteId: String): List + + /** Pending family invites addressed to the caller's email. */ + suspend fun fetchPendingInvites(): List + + /** Creates a family owned by [ownerId] and returns the stored row. */ + suspend fun createFamily(name: String, ownerId: String): RemoteFamily + + /** Renames [familyRemoteId]. */ + suspend fun renameFamily(familyRemoteId: String, name: String) + + /** Deletes [familyRemoteId] for everyone. */ + suspend fun deleteFamily(familyRemoteId: String) + + /** Invites [email] to [familyRemoteId] as a member, attributed to [invitedBy]. */ + suspend fun invite(familyRemoteId: String, email: String, invitedBy: String) + + /** + * Deletes the member row [memberId] — removing a member, cancelling, or declining an invite. + */ + suspend fun deleteMember(memberId: String) + + /** Deletes the caller's own membership of [familyRemoteId]. */ + suspend fun leaveFamily(familyRemoteId: String, userId: String) + + /** Marks the invite [memberId] accepted and links it to [userId]. */ + suspend fun acceptInvite(memberId: String, userId: String) + + /** Marks the invite [memberId] rejected, keeping the row so the owner sees the outcome. */ + suspend fun rejectInvite(memberId: String) +} diff --git a/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/RemoteFamilyModels.kt b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/RemoteFamilyModels.kt new file mode 100644 index 000000000..206fd8148 --- /dev/null +++ b/client/family/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/remote/RemoteFamilyModels.kt @@ -0,0 +1,52 @@ +package com.plusmobileapps.chefmate.family.data.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** A row of the `families` table, or of the `current_family()` RPC which returns the same shape. */ +@Serializable +data class RemoteFamily( + val id: String? = null, + val name: String, + @SerialName("owner_id") val ownerId: String, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, +) + +/** A row of the `family_members` table, used for inserts (invites) and status updates. */ +@Serializable +data class RemoteFamilyMember( + val id: String? = null, + @SerialName("family_id") val familyId: String, + @SerialName("user_id") val userId: String? = null, + @SerialName("invited_email") val invitedEmail: String, + @SerialName("invited_by") val invitedBy: String? = null, + val role: String = "member", + val status: String = "pending", +) + +/** + * A row of the `family_members_with_profiles` RPC: every member plus the synthesized owner entry, + * with names and avatars resolved from `auth.users` (which clients can't read directly). Same + * 7-column shape as `grocery_list_collaborators` and `recipe_book_collaborators`. + */ +@Serializable +data class RemoteFamilyCollaborator( + @SerialName("member_id") val memberId: String?, + val email: String, + val name: String?, + val role: String, + val status: String, + @SerialName("is_owner") val isOwner: Boolean, + @SerialName("avatar_url") val avatarUrl: String? = null, +) + +/** A row of the `family_pending_invites` RPC: invites addressed to the caller. */ +@Serializable +data class RemoteFamilyInvite( + @SerialName("member_id") val memberId: String, + @SerialName("family_id") val familyId: String, + @SerialName("family_name") val familyName: String, + val role: String, + val status: String, +) diff --git a/client/family/data/testing/build.gradle.kts b/client/family/data/testing/build.gradle.kts new file mode 100644 index 000000000..bc1952143 --- /dev/null +++ b/client/family/data/testing/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { alias(libs.plugins.kmpLibrary) } + +kotlin { + sourceSets { + commonMain.dependencies { + api(projects.client.family.data.public) + implementation(projects.client.shared) + } + } +} + +plusLibrary { namespace = "com.plusmobileapps.chefmate.family.data.testing" } diff --git a/client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRemoteDataSource.kt b/client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRemoteDataSource.kt new file mode 100644 index 000000000..2083ac0a9 --- /dev/null +++ b/client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRemoteDataSource.kt @@ -0,0 +1,157 @@ +package com.plusmobileapps.chefmate.family.data.testing + +import com.plusmobileapps.chefmate.family.data.remote.FamilyRemoteDataSource +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamily +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyCollaborator +import com.plusmobileapps.chefmate.family.data.remote.RemoteFamilyInvite +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * In-memory [FamilyRemoteDataSource] standing in for Supabase. Mutations update the backing state + * the way the real RPCs would, so a repository test can drive create → invite → accept end to end. + */ +class FakeFamilyRemoteDataSource : FamilyRemoteDataSource { + + /** The family the *caller* currently belongs to, as `current_family()` would report it. */ + var currentFamily: RemoteFamily? = null + + var members: MutableList = mutableListOf() + + var pendingInvites: MutableList = mutableListOf() + + /** Set to have every call throw, exercising the repository's failure handling. */ + var errorToThrow: Exception? = null + + val invitedEmails: MutableList = mutableListOf() + val deletedMemberIds: MutableList = mutableListOf() + val rejectedMemberIds: MutableList = mutableListOf() + var deletedFamilyId: String? = null + private set + + var leftFamilyId: String? = null + private set + + private val changes = + MutableSharedFlow( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Emits a realtime change so the repository re-reconciles. */ + suspend fun emitChange() { + changes.emit(Unit) + } + + override fun observeChanges(): Flow = changes + + override suspend fun fetchCurrentFamily(): RemoteFamily? { + throwIfConfigured() + return currentFamily + } + + override suspend fun fetchMembers(familyRemoteId: String): List { + throwIfConfigured() + return members.toList() + } + + override suspend fun fetchPendingInvites(): List { + throwIfConfigured() + return pendingInvites.toList() + } + + override suspend fun createFamily(name: String, ownerId: String): RemoteFamily { + throwIfConfigured() + val created = RemoteFamily(id = "family-remote-1", name = name, ownerId = ownerId) + currentFamily = created + members = + mutableListOf( + RemoteFamilyCollaborator( + memberId = null, + email = "owner@example.com", + name = null, + role = "owner", + status = "accepted", + isOwner = true, + ) + ) + return created + } + + override suspend fun renameFamily(familyRemoteId: String, name: String) { + throwIfConfigured() + currentFamily = currentFamily?.copy(name = name) + } + + override suspend fun deleteFamily(familyRemoteId: String) { + throwIfConfigured() + deletedFamilyId = familyRemoteId + currentFamily = null + members.clear() + } + + override suspend fun invite(familyRemoteId: String, email: String, invitedBy: String) { + throwIfConfigured() + invitedEmails += email + members += + RemoteFamilyCollaborator( + memberId = "member-${members.size + 1}", + email = email, + name = null, + role = "member", + status = "pending", + isOwner = false, + ) + } + + override suspend fun deleteMember(memberId: String) { + throwIfConfigured() + deletedMemberIds += memberId + members.removeAll { it.memberId == memberId } + } + + override suspend fun leaveFamily(familyRemoteId: String, userId: String) { + throwIfConfigured() + leftFamilyId = familyRemoteId + currentFamily = null + members.clear() + } + + override suspend fun acceptInvite(memberId: String, userId: String) { + throwIfConfigured() + val invite = pendingInvites.firstOrNull { it.memberId == memberId } ?: return + pendingInvites.removeAll { it.memberId == memberId } + currentFamily = + RemoteFamily(id = invite.familyId, name = invite.familyName, ownerId = "other-owner") + members = + mutableListOf( + RemoteFamilyCollaborator( + memberId = null, + email = "owner@example.com", + name = null, + role = "owner", + status = "accepted", + isOwner = true, + ), + RemoteFamilyCollaborator( + memberId = memberId, + email = "invitee@example.com", + name = null, + role = "member", + status = "accepted", + isOwner = false, + ), + ) + } + + override suspend fun rejectInvite(memberId: String) { + throwIfConfigured() + rejectedMemberIds += memberId + pendingInvites.removeAll { it.memberId == memberId } + } + + private fun throwIfConfigured() { + errorToThrow?.let { throw it } + } +} diff --git a/client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRepository.kt b/client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRepository.kt new file mode 100644 index 000000000..0a038535b --- /dev/null +++ b/client/family/data/testing/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/data/testing/FakeFamilyRepository.kt @@ -0,0 +1,135 @@ +package com.plusmobileapps.chefmate.family.data.testing + +import com.plusmobileapps.chefmate.family.data.AlreadyInFamilyException +import com.plusmobileapps.chefmate.family.data.Family +import com.plusmobileapps.chefmate.family.data.FamilyInvite +import com.plusmobileapps.chefmate.family.data.FamilyMember +import com.plusmobileapps.chefmate.family.data.FamilyMemberStatus +import com.plusmobileapps.chefmate.family.data.FamilyRepository +import com.plusmobileapps.chefmate.family.data.FamilyRole +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * In-memory [FamilyRepository] for tests and previews. The backing [MutableStateFlow]s are public + * so a test can seed state directly or assert on the result of a call. + */ +class FakeFamilyRepository( + private val familyState: MutableStateFlow = MutableStateFlow(null), + private val membersState: MutableStateFlow> = MutableStateFlow(emptyList()), + private val invitesState: MutableStateFlow> = MutableStateFlow(emptyList()), +) : FamilyRepository { + + /** Set to have the next mutating call throw, exercising error paths. */ + var errorToThrow: Exception? = null + + var refreshCount: Int = 0 + private set + + var clearLocalDataCount: Int = 0 + private set + + val invitedEmails: MutableList = mutableListOf() + val removedMemberIds: MutableList = mutableListOf() + val acceptedInviteIds: MutableList = mutableListOf() + val declinedInviteIds: MutableList = mutableListOf() + + override val family: StateFlow = familyState + + override fun members(): Flow> = membersState + + override fun pendingInvites(): Flow> = invitesState + + override suspend fun createFamily(name: String) { + throwIfConfigured() + if (familyState.value != null) throw AlreadyInFamilyException() + familyState.value = + Family(id = 1L, remoteId = "family-1", name = name, isOwnedByCurrentUser = true) + membersState.value = + listOf( + FamilyMember( + id = null, + email = "owner@example.com", + role = FamilyRole.OWNER, + status = FamilyMemberStatus.ACCEPTED, + isOwner = true, + ) + ) + } + + override suspend fun renameFamily(name: String) { + throwIfConfigured() + familyState.value = familyState.value?.copy(name = name) + } + + override suspend fun invite(email: String) { + throwIfConfigured() + val normalized = email.trim().lowercase() + invitedEmails += normalized + membersState.value = + membersState.value + + FamilyMember( + id = "member-${membersState.value.size + 1}", + email = normalized, + role = FamilyRole.MEMBER, + status = FamilyMemberStatus.PENDING, + ) + } + + override suspend fun removeMember(memberId: String) { + throwIfConfigured() + removedMemberIds += memberId + membersState.value = membersState.value.filterNot { it.id == memberId } + } + + override suspend fun leaveFamily() { + throwIfConfigured() + familyState.value = null + membersState.value = emptyList() + } + + override suspend fun deleteFamily() { + throwIfConfigured() + familyState.value = null + membersState.value = emptyList() + } + + override suspend fun acceptInvite(memberId: String) { + throwIfConfigured() + if (familyState.value != null) throw AlreadyInFamilyException() + acceptedInviteIds += memberId + val invite = invitesState.value.firstOrNull { it.memberId == memberId } + invitesState.value = invitesState.value.filterNot { it.memberId == memberId } + if (invite != null) { + familyState.value = + Family( + id = 1L, + remoteId = "family-1", + name = invite.familyName, + isOwnedByCurrentUser = false, + ) + } + } + + override suspend fun declineInvite(memberId: String) { + throwIfConfigured() + declinedInviteIds += memberId + invitesState.value = invitesState.value.filterNot { it.memberId == memberId } + } + + override suspend fun refresh() { + refreshCount++ + } + + override suspend fun clearLocalData() { + clearLocalDataCount++ + familyState.value = null + membersState.value = emptyList() + invitesState.value = emptyList() + } + + private fun throwIfConfigured() { + errorToThrow?.let { throw it } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 637b63630..d32657102 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -84,6 +84,18 @@ include(":client:featureflag:testing") include(":client:database:testing") +include(":client:family:core:impl") + +include(":client:family:core:impl-robots") + +include(":client:family:core:public") + +include(":client:family:data:impl") + +include(":client:family:data:public") + +include(":client:family:data:testing") + include(":client:grocery:autocomplete:impl") include(":client:grocery:autocomplete:impl-robots") From d57e5c3911c2832ec9d512fbec612f72f1073324 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:15:54 -0700 Subject: [PATCH 5/9] feat(notifications): surface family invites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppNotification.FamilyInvite joins the grocery and recipe-book kinds, so family invites land in the existing inbox with no new mechanism — the invite email already points at /notifications, which means no new Android pathPrefix and no AASA change in the site repo. Unlike the other two kinds there's no role to show, since every family invite is for a member. Accepting while already in a family fails at the database. That's actionable rather than transient, so it gets its own message telling the user to leave first, not the generic "try again". Co-Authored-By: Claude Opus 5 --- client/notifications/data/impl/build.gradle.kts | 2 ++ .../data/impl/NotificationsRepositoryImpl.kt | 17 ++++++++++++++++- .../impl/NotificationsRepositoryImplTest.kt | 3 +++ .../notifications/data/public/build.gradle.kts | 1 + .../notifications/data/AppNotification.kt | 15 ++++++++++++--- client/notifications/impl/build.gradle.kts | 2 ++ .../impl/NotificationsViewModel.kt | 14 +++++++++++++- .../composeResources/values/strings.xml | 2 ++ .../notifications/ui/NotificationsScreen.kt | 6 ++++++ 9 files changed, 57 insertions(+), 5 deletions(-) diff --git a/client/notifications/data/impl/build.gradle.kts b/client/notifications/data/impl/build.gradle.kts index b39709b9f..1476b23f7 100644 --- a/client/notifications/data/impl/build.gradle.kts +++ b/client/notifications/data/impl/build.gradle.kts @@ -4,11 +4,13 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.client.notifications.data.public) + implementation(projects.client.family.data.public) implementation(projects.client.grocery.data.public) implementation(projects.client.recipebook.data.public) implementation(projects.client.shared) } commonTest.dependencies { + implementation(projects.client.family.data.testing) implementation(projects.client.grocery.data.testing) implementation(projects.client.recipebook.data.testing) } diff --git a/client/notifications/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImpl.kt b/client/notifications/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImpl.kt index 4654e3b7a..7a40bc342 100644 --- a/client/notifications/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImpl.kt +++ b/client/notifications/data/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.plusmobileapps.chefmate.notifications.data.impl import com.plusmobileapps.chefmate.di.AppScope +import com.plusmobileapps.chefmate.family.data.FamilyRepository import com.plusmobileapps.chefmate.grocery.data.GroceryRepository import com.plusmobileapps.chefmate.notifications.data.AppNotification import com.plusmobileapps.chefmate.notifications.data.NotificationsRepository @@ -23,6 +24,7 @@ import kotlinx.coroutines.flow.update class NotificationsRepositoryImpl( private val groceryRepository: GroceryRepository, private val recipeBookCollaborationRepository: RecipeBookCollaborationRepository, + private val familyRepository: FamilyRepository, ) : NotificationsRepository { // Bumped by refresh()/accept()/decline() to force both the grocery flow and the recipe-book @@ -42,7 +44,10 @@ class NotificationsRepositoryImpl( .getOrDefault(emptyList()) ) }, - ) { groceryInvites, recipeBookInvites -> + // Family invites come from a hot flow the repository keeps fresh over realtime, so + // unlike the recipe-book source this needs no re-fetch wrapper. + familyRepository.pendingInvites(), + ) { groceryInvites, recipeBookInvites, familyInvites -> groceryInvites.map { AppNotification.GroceryInvite( memberId = it.memberId, @@ -56,6 +61,12 @@ class NotificationsRepositoryImpl( bookName = it.bookName, role = it.role, ) + } + + familyInvites.map { + AppNotification.FamilyInvite( + memberId = it.memberId, + familyName = it.familyName, + ) } } } @@ -66,6 +77,9 @@ class NotificationsRepositoryImpl( groceryRepository.acceptInvitation(notification.memberId) is AppNotification.RecipeBookInvite -> recipeBookCollaborationRepository.acceptInvite(notification.memberId) + // Throws AlreadyInFamilyException when the user is already in a family — a user can + // only belong to one. The caller turns that into a message telling them to leave first. + is AppNotification.FamilyInvite -> familyRepository.acceptInvite(notification.memberId) } refresh() } @@ -76,6 +90,7 @@ class NotificationsRepositoryImpl( groceryRepository.rejectInvitation(notification.memberId) is AppNotification.RecipeBookInvite -> recipeBookCollaborationRepository.declineInvite(notification.memberId) + is AppNotification.FamilyInvite -> familyRepository.declineInvite(notification.memberId) } refresh() } diff --git a/client/notifications/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImplTest.kt b/client/notifications/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImplTest.kt index 985e3ab8b..50cb8cc9a 100644 --- a/client/notifications/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImplTest.kt +++ b/client/notifications/data/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/notifications/data/impl/NotificationsRepositoryImplTest.kt @@ -1,6 +1,7 @@ package com.plusmobileapps.chefmate.notifications.data.impl import app.cash.turbine.test +import com.plusmobileapps.chefmate.family.data.testing.FakeFamilyRepository import com.plusmobileapps.chefmate.grocery.data.GroceryListInvite import com.plusmobileapps.chefmate.grocery.data.ListRole import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryRepository @@ -18,11 +19,13 @@ class NotificationsRepositoryImplTest { private val grocery = FakeGroceryRepository() private val recipeBook = FakeRecipeBookCollaborationRepository() + private val family = FakeFamilyRepository() private val repository = NotificationsRepositoryImpl( groceryRepository = grocery, recipeBookCollaborationRepository = recipeBook, + familyRepository = family, ) @Test diff --git a/client/notifications/data/public/build.gradle.kts b/client/notifications/data/public/build.gradle.kts index 34b418520..85dbcbca0 100644 --- a/client/notifications/data/public/build.gradle.kts +++ b/client/notifications/data/public/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kmpLibrary) } kotlin { sourceSets { commonMain.dependencies { + api(projects.client.family.data.public) api(projects.client.grocery.data.public) api(projects.client.recipebook.data.public) implementation(projects.client.shared) diff --git a/client/notifications/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/AppNotification.kt b/client/notifications/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/AppNotification.kt index d1d7a4a6d..68f380138 100644 --- a/client/notifications/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/AppNotification.kt +++ b/client/notifications/data/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/data/AppNotification.kt @@ -4,9 +4,9 @@ import com.plusmobileapps.chefmate.grocery.data.ListRole import com.plusmobileapps.chefmate.recipebook.data.RecipeBookRole /** - * An item shown in the in-app Notifications section. Today the only kind is a pending collaboration - * invite (to a grocery list or a recipe book) awaiting the current user's Accept/Decline. New kinds - * can be added here as the feature grows. + * An item shown in the in-app Notifications section. Today every kind is a pending collaboration + * invite (to a grocery list, a recipe book, or a family) awaiting the current user's + * Accept/Decline. New kinds can be added here as the feature grows. * * [key] is a stable, type-qualified identifier safe to use as a list key and to track in-flight * actions — the underlying member ids are unique per table but could otherwise collide across @@ -31,4 +31,13 @@ sealed interface AppNotification { override val key: String get() = "recipe_book:$memberId" } + + /** + * A pending invite to join the family named [familyName]. Unlike the other two there's no role + * to show — a family has only owner and member, and every invite is for a member. + */ + data class FamilyInvite(val memberId: String, val familyName: String) : AppNotification { + override val key: String + get() = "family:$memberId" + } } diff --git a/client/notifications/impl/build.gradle.kts b/client/notifications/impl/build.gradle.kts index 3f48ca483..377d50c8f 100644 --- a/client/notifications/impl/build.gradle.kts +++ b/client/notifications/impl/build.gradle.kts @@ -13,12 +13,14 @@ kotlin { implementation(libs.arkivanov.decompose.core) implementation(projects.client.shared) implementation(projects.client.auth.data.public) + implementation(projects.client.family.data.public) implementation(projects.client.toast.public) implementation(compose.components.resources) } commonTest.dependencies { implementation(projects.client.notifications.data.testing) implementation(projects.client.auth.data.testing) + implementation(projects.client.family.data.testing) implementation(projects.client.toast.testing) } } diff --git a/client/notifications/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/impl/NotificationsViewModel.kt b/client/notifications/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/impl/NotificationsViewModel.kt index 0712c5dd3..292db520f 100644 --- a/client/notifications/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/impl/NotificationsViewModel.kt +++ b/client/notifications/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/impl/NotificationsViewModel.kt @@ -2,11 +2,13 @@ package com.plusmobileapps.chefmate.notifications.impl import chefmate.client.notifications.public.generated.resources.Res import chefmate.client.notifications.public.generated.resources.notifications_accept_error +import chefmate.client.notifications.public.generated.resources.notifications_already_in_family import chefmate.client.notifications.public.generated.resources.notifications_decline_error import com.plusmobileapps.chefmate.ViewModel import com.plusmobileapps.chefmate.auth.data.AuthState import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository import com.plusmobileapps.chefmate.di.Main +import com.plusmobileapps.chefmate.family.data.AlreadyInFamilyException import com.plusmobileapps.chefmate.notifications.NotificationsBloc.Model import com.plusmobileapps.chefmate.notifications.data.AppNotification import com.plusmobileapps.chefmate.notifications.data.NotificationsRepository @@ -72,7 +74,17 @@ class NotificationsViewModel( processing.update { it.add(notification.key) } scope.launch { runCatching { action(notification) } - .onFailure { toastService.show(errorMessage.asTextData()) } + .onFailure { error -> + // A user can only belong to one family, so this failure is actionable — tell + // them what to do rather than showing the generic "try again". + val message = + if (error is AlreadyInFamilyException) { + Res.string.notifications_already_in_family + } else { + errorMessage + } + toastService.show(message.asTextData()) + } processing.update { it.remove(notification.key) } } } diff --git a/client/notifications/public/src/commonMain/composeResources/values/strings.xml b/client/notifications/public/src/commonMain/composeResources/values/strings.xml index a68839d13..6522a79f7 100644 --- a/client/notifications/public/src/commonMain/composeResources/values/strings.xml +++ b/client/notifications/public/src/commonMain/composeResources/values/strings.xml @@ -2,6 +2,7 @@ Notifications You’ve been invited to collaborate on the grocery list “{list}”. You’ve been invited to collaborate on the recipe book “{book}”. + You’ve been invited to join the family “{family}”. You’ll share grocery lists, recipe books, and a meal plan. Accept Decline You’re all caught up @@ -9,5 +10,6 @@ You’re signed out Sign in to see invitations addressed to your account. Couldn’t accept the invitation. Please try again. + You’re already in a family. Leave it first to join another. Couldn’t decline the invitation. Please try again. diff --git a/client/notifications/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/ui/NotificationsScreen.kt b/client/notifications/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/ui/NotificationsScreen.kt index 53e4b3626..f86481395 100644 --- a/client/notifications/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/ui/NotificationsScreen.kt +++ b/client/notifications/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/notifications/ui/NotificationsScreen.kt @@ -25,6 +25,7 @@ import chefmate.client.notifications.public.generated.resources.notifications_ac import chefmate.client.notifications.public.generated.resources.notifications_decline import chefmate.client.notifications.public.generated.resources.notifications_empty_message import chefmate.client.notifications.public.generated.resources.notifications_empty_title +import chefmate.client.notifications.public.generated.resources.notifications_family_invite_message import chefmate.client.notifications.public.generated.resources.notifications_grocery_invite_message import chefmate.client.notifications.public.generated.resources.notifications_recipe_book_invite_message import chefmate.client.notifications.public.generated.resources.notifications_signed_out @@ -185,4 +186,9 @@ private fun AppNotification.message(): TextData = resource = Res.string.notifications_recipe_book_invite_message, "book" to FixedString(bookName), ) + is AppNotification.FamilyInvite -> + PhraseModel( + resource = Res.string.notifications_family_invite_message, + "family" to FixedString(familyName), + ) } From c12730b405e9aaa82d8c00b2e867af839c9a4da9 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:16:15 -0700 Subject: [PATCH 6/9] feat(family): open the Family screen from the More tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One row, directly below Notifications and behind the same gate — a family is keyed on account emails, so it's hidden for signed-out and anonymous users. It sits on the More tab rather than under Settings because it's account state, not an app preference. Routes the way Notifications does: SettingsBloc output -> BottomNavBloc -> RootBlocImpl. Co-Authored-By: Claude Opus 5 --- .../bottomnav/impl/BottomNavBlocImpl.kt | 1 + .../recipe/bottomnav/BottomNavBloc.kt | 2 ++ client/root/impl/build.gradle.kts | 1 + .../chefmate/root/RootBlocImpl.kt | 27 +++++++++++++++++ .../chefmate/root/RootBlocTest.kt | 29 +++++++++++++++++++ client/root/public/build.gradle.kts | 1 + .../plusmobileapps/chefmate/root/RootBloc.kt | 3 ++ .../settings/impl/SettingsBlocImpl.kt | 4 +++ .../composeResources/values/strings.xml | 1 + .../chefmate/settings/SettingsBloc.kt | 4 +++ .../chefmate/settings/ui/SettingsScreen.kt | 16 ++++++++-- 11 files changed, 87 insertions(+), 2 deletions(-) diff --git a/client/bottomnav/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/impl/BottomNavBlocImpl.kt b/client/bottomnav/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/impl/BottomNavBlocImpl.kt index d3b0e8359..5b268620a 100644 --- a/client/bottomnav/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/impl/BottomNavBlocImpl.kt +++ b/client/bottomnav/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/impl/BottomNavBlocImpl.kt @@ -240,6 +240,7 @@ class BottomNavBlocImpl( SettingsBloc.Output.OpenSignUp -> OpenSignUp SettingsBloc.Output.OpenManageProfile -> OpenManageProfile SettingsBloc.Output.OpenNotifications -> BottomNavBloc.Output.OpenNotifications + SettingsBloc.Output.OpenFamily -> BottomNavBloc.Output.OpenFamily SettingsBloc.Output.OpenAppSettings -> OpenAppSettings SettingsBloc.Output.OpenAiChat -> BottomNavBloc.Output.OpenAiChat SettingsBloc.Output.OpenDeveloperSettings -> BottomNavBloc.Output.OpenDeveloperSettings diff --git a/client/bottomnav/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/BottomNavBloc.kt b/client/bottomnav/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/BottomNavBloc.kt index 4634a9d1a..ea029fefe 100644 --- a/client/bottomnav/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/BottomNavBloc.kt +++ b/client/bottomnav/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/recipe/bottomnav/BottomNavBloc.kt @@ -91,6 +91,8 @@ interface BottomNavBloc : BackHandlerOwner, BackClickBloc, ComposeScreen { data object OpenNotifications : Output() + data object OpenFamily : Output() + data object OpenAppSettings : Output() data object OpenAiChat : Output() diff --git a/client/root/impl/build.gradle.kts b/client/root/impl/build.gradle.kts index 7f83a7e03..b74131560 100644 --- a/client/root/impl/build.gradle.kts +++ b/client/root/impl/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { implementation(projects.client.cook.public) implementation(projects.client.featureflag.public) implementation(projects.client.grocery.core.public) + implementation(projects.client.family.core.public) implementation(projects.client.notifications.public) implementation(projects.client.onboarding.public) implementation(projects.client.profile.public) diff --git a/client/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBlocImpl.kt b/client/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBlocImpl.kt index 284ae686e..529eac601 100644 --- a/client/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBlocImpl.kt +++ b/client/root/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBlocImpl.kt @@ -24,6 +24,7 @@ import com.plusmobileapps.chefmate.cook.CookModeBloc import com.plusmobileapps.chefmate.devsettings.DeveloperSettingsBloc import com.plusmobileapps.chefmate.di.AppScope import com.plusmobileapps.chefmate.di.OnboardingRepository +import com.plusmobileapps.chefmate.family.core.FamilyBloc import com.plusmobileapps.chefmate.featureflag.FeatureFlags import com.plusmobileapps.chefmate.featureflag.FeatureFlagsBloc import com.plusmobileapps.chefmate.grocery.core.edit.EditGroceryListBloc @@ -64,6 +65,7 @@ class RootBlocImpl( private val settingsRoot: SettingsRootBloc.Factory, private val manageProfile: ManageProfileBloc.Factory, private val notifications: NotificationsBloc.Factory, + private val family: FamilyBloc.Factory, private val developerSettings: DeveloperSettingsBloc.Factory, private val cookMode: CookModeBloc.Factory, private val featureFlags: FeatureFlags, @@ -308,6 +310,11 @@ class RootBlocImpl( ) ) + Configuration.Family -> + RootBloc.Child.Family( + bloc = family.create(context = context, output = ::handleFamilyOutput) + ) + Configuration.DeveloperSettings -> RootBloc.Child.DeveloperSettings( bloc = @@ -456,6 +463,10 @@ class RootBlocImpl( navigation.bringToFront(Configuration.Notifications) } + BottomNavBloc.Output.OpenFamily -> { + navigation.bringToFront(Configuration.Family) + } + BottomNavBloc.Output.OpenAppSettings -> { navigation.bringToFront(Configuration.SettingsRoot()) } @@ -561,6 +572,20 @@ class RootBlocImpl( } } + private fun handleFamilyOutput(output: FamilyBloc.Output) { + when (output) { + FamilyBloc.Output.Back -> navigation.pop() + FamilyBloc.Output.OpenSignIn -> + navigation.bringToFront( + Configuration.Authentication(AuthenticationBloc.Props.SignIn) + ) + FamilyBloc.Output.OpenSignUp -> + navigation.bringToFront( + Configuration.Authentication(AuthenticationBloc.Props.SignUp) + ) + } + } + private fun handleDeveloperSettingsOutput(output: DeveloperSettingsBloc.Output) { when (output) { DeveloperSettingsBloc.Output.Back -> navigation.pop() @@ -734,6 +759,8 @@ class RootBlocImpl( @Serializable data object Notifications : Configuration() + @Serializable data object Family : Configuration() + @Serializable data object DeveloperSettings : Configuration() @Serializable data object FeatureFlags : Configuration() diff --git a/client/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/root/RootBlocTest.kt b/client/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/root/RootBlocTest.kt index 06afad5da..a80ae4248 100644 --- a/client/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/root/RootBlocTest.kt +++ b/client/root/impl/src/commonTest/kotlin/com/plusmobileapps/chefmate/root/RootBlocTest.kt @@ -12,6 +12,7 @@ import com.plusmobileapps.chefmate.auth.ui.otp.OtpBloc import com.plusmobileapps.chefmate.browser.BrowserRootBloc import com.plusmobileapps.chefmate.cook.CookModeBloc import com.plusmobileapps.chefmate.di.OnboardingRepository +import com.plusmobileapps.chefmate.family.core.FamilyBloc import com.plusmobileapps.chefmate.featureflag.testing.FakeFeatureFlags import com.plusmobileapps.chefmate.notifications.NotificationsBloc import com.plusmobileapps.chefmate.onboarding.OnboardingRootBloc @@ -46,6 +47,7 @@ class RootBlocTest { var notificationsOutput: Consumer = Consumer {} + var familyOutput: Consumer = Consumer {} var developerSettingsOutput: Consumer = Consumer {} @@ -130,6 +132,10 @@ class RootBlocTest { notificationsOutput = output mock() }, + family = { _, output -> + familyOutput = output + mock() + }, developerSettings = { _, output -> developerSettingsOutput = output mock() @@ -359,6 +365,29 @@ class RootBlocTest { authProps shouldBe AuthenticationBloc.Props.SignUp } + @Test + fun When_bottom_nav_opens_family_Then_family_is_shown() { + bottomNavOutput.onNext(BottomNavBloc.Output.OpenFamily) + rootBloc.instance() should instanceOf() + rootBloc.state.value.backStack.size shouldBe 1 + } + + @Test + fun Given_family_When_back_outputted_Then_bottom_nav_is_shown() { + bottomNavOutput.onNext(BottomNavBloc.Output.OpenFamily) + rootBloc.instance() should instanceOf() + familyOutput.onNext(FamilyBloc.Output.Back) + rootBloc.instance() should instanceOf() + } + + @Test + fun Given_family_When_open_sign_in_outputted_Then_authentication_is_shown() { + bottomNavOutput.onNext(BottomNavBloc.Output.OpenFamily) + familyOutput.onNext(FamilyBloc.Output.OpenSignIn) + rootBloc.instance() should instanceOf() + authProps shouldBe AuthenticationBloc.Props.SignIn + } + @Test fun When_bottom_nav_opens_grocery_autocomplete_settings_Then_settings_root_deep_links() { bottomNavOutput.onNext(BottomNavBloc.Output.OpenGroceryAutocompleteSettings) diff --git a/client/root/public/build.gradle.kts b/client/root/public/build.gradle.kts index 64edfda81..c5d3e0d88 100644 --- a/client/root/public/build.gradle.kts +++ b/client/root/public/build.gradle.kts @@ -13,6 +13,7 @@ kotlin { api(projects.client.developerSettings.public) api(projects.client.featureflag.public) api(projects.client.grocery.core.public) + api(projects.client.family.core.public) api(projects.client.notifications.public) api(projects.client.onboarding.public) api(projects.client.profile.public) diff --git a/client/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBloc.kt b/client/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBloc.kt index 099ebdda7..997711e50 100644 --- a/client/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBloc.kt +++ b/client/root/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/root/RootBloc.kt @@ -11,6 +11,7 @@ import com.plusmobileapps.chefmate.auth.ui.otp.OtpBloc import com.plusmobileapps.chefmate.browser.BrowserRootBloc import com.plusmobileapps.chefmate.cook.CookModeBloc import com.plusmobileapps.chefmate.devsettings.DeveloperSettingsBloc +import com.plusmobileapps.chefmate.family.core.FamilyBloc import com.plusmobileapps.chefmate.featureflag.FeatureFlagsBloc import com.plusmobileapps.chefmate.grocery.core.edit.EditGroceryListBloc import com.plusmobileapps.chefmate.notifications.NotificationsBloc @@ -73,6 +74,8 @@ interface RootBloc : BackHandlerOwner, BackClickBloc { data class Notifications(override val bloc: NotificationsBloc) : Child() + data class Family(override val bloc: FamilyBloc) : Child() + data class DeveloperSettings(override val bloc: DeveloperSettingsBloc) : Child() data class FeatureFlags(override val bloc: FeatureFlagsBloc) : Child() diff --git a/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/SettingsBlocImpl.kt b/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/SettingsBlocImpl.kt index 02b2802e7..99921e143 100644 --- a/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/SettingsBlocImpl.kt +++ b/client/settings/impl/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/impl/SettingsBlocImpl.kt @@ -72,6 +72,10 @@ class SettingsBlocImpl( output.onNext(Output.OpenNotifications) } + override fun onFamilyClicked() { + output.onNext(Output.OpenFamily) + } + override fun onUrlClicked(url: String) { output.onNext(Output.OpenUrl(url)) } diff --git a/client/settings/public/src/commonMain/composeResources/values/strings.xml b/client/settings/public/src/commonMain/composeResources/values/strings.xml index 8bf54eb01..be93c0836 100644 --- a/client/settings/public/src/commonMain/composeResources/values/strings.xml +++ b/client/settings/public/src/commonMain/composeResources/values/strings.xml @@ -9,6 +9,7 @@ Sign Up Manage Profile Notifications + Family Hello {name}! Please check your email ({email}) to verify your account before signing in. You’re using ChefMate as a guest. Sign up to back up your recipes across devices. diff --git a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/SettingsBloc.kt b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/SettingsBloc.kt index f0c31f9b4..eb3c2e375 100644 --- a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/SettingsBloc.kt +++ b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/SettingsBloc.kt @@ -31,6 +31,8 @@ interface SettingsBloc : ComposeScreen { fun onNotificationsClicked() + fun onFamilyClicked() + fun onUrlClicked(url: String) fun onAppSettingsClicked() @@ -67,6 +69,8 @@ interface SettingsBloc : ComposeScreen { data object OpenNotifications : Output() + data object OpenFamily : Output() + data object OpenAppSettings : Output() data object OpenAiChat : Output() diff --git a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/SettingsScreen.kt b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/SettingsScreen.kt index bf9048c79..28bdbebc9 100644 --- a/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/SettingsScreen.kt +++ b/client/settings/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/ui/SettingsScreen.kt @@ -39,6 +39,7 @@ import chefmate.client.settings.public.generated.resources.more import chefmate.client.settings.public.generated.resources.privacy_policy import chefmate.client.settings.public.generated.resources.settings import chefmate.client.settings.public.generated.resources.settings_ai_chat +import chefmate.client.settings.public.generated.resources.settings_family import chefmate.client.settings.public.generated.resources.settings_guest_banner import chefmate.client.settings.public.generated.resources.settings_notifications import chefmate.client.settings.public.generated.resources.settings_replay_onboarding @@ -135,14 +136,19 @@ fun SettingsScreen(bloc: SettingsBloc, modifier: Modifier = Modifier) { ) } } - // Notifications aggregate collaboration invites, which are addressed to a real account - // — so the row is only meaningful for a signed-in, non-anonymous user. + // Notifications aggregate collaboration invites, and a family is keyed on account + // emails — so both rows are only meaningful for a signed-in, non-anonymous user. if (viewState.isAuthenticated && !viewState.isAnonymous) { HorizontalDivider() SettingsRow( name = Res.string.settings_notifications.asTextData(), onClick = bloc::onNotificationsClicked, ) + HorizontalDivider() + SettingsRow( + name = Res.string.settings_family.asTextData(), + onClick = bloc::onFamilyClicked, + ) } HorizontalDivider() SettingsRow( @@ -313,6 +319,8 @@ private val previewBlocUnauthenticated = override fun onNotificationsClicked() = Unit + override fun onFamilyClicked() = Unit + override fun onUrlClicked(url: String) = Unit override fun onAppSettingsClicked() = Unit @@ -353,6 +361,8 @@ private val previewBlocAuthenticated = override fun onNotificationsClicked() = Unit + override fun onFamilyClicked() = Unit + override fun onUrlClicked(url: String) = Unit override fun onAppSettingsClicked() = Unit @@ -389,6 +399,8 @@ private val previewBlocAnonymous = override fun onNotificationsClicked() = Unit + override fun onFamilyClicked() = Unit + override fun onUrlClicked(url: String) = Unit override fun onAppSettingsClicked() = Unit From 79086ed98f79da48ffb00b23a8319b99e041dfed Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:16:16 -0700 Subject: [PATCH 7/9] test(family): add Family screen snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the states that differ structurally rather than cosmetically: signed out, no family yet, owner (rename/invite/remove/delete), member (read-only plus Leave), and mid-rename. Empty and owner also in dark. The owner/member pair is the one worth eyeballing on a diff — it's what proves the admin controls are actually gated. Co-Authored-By: Claude Opus 5 --- .../chefmate/family/core/ui/FamilyPreviews.kt | 121 ++++++++++++++++++ client/ui/screenshot-test/build.gradle.kts | 1 + .../ui/screenshot/FamilyScreenshotTest.kt | 63 +++++++++ .../FamilyEmptyDarkScreenshot_907cdd58_0.png | 3 + .../FamilyEmptyScreenshot_e0e29121_0.png | 3 + .../FamilyMemberScreenshot_e0e29121_0.png | 3 + .../FamilyOwnerDarkScreenshot_907cdd58_0.png | 3 + .../FamilyOwnerLightScreenshot_e0e29121_0.png | 3 + .../FamilyRenamingScreenshot_e0e29121_0.png | 3 + .../FamilySignedOutScreenshot_e0e29121_0.png | 3 + 10 files changed, 206 insertions(+) create mode 100644 client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyPreviews.kt create mode 100644 client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTest.kt create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyDarkScreenshot_907cdd58_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyScreenshot_e0e29121_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyMemberScreenshot_e0e29121_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerDarkScreenshot_907cdd58_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerLightScreenshot_e0e29121_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyRenamingScreenshot_e0e29121_0.png create mode 100644 client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilySignedOutScreenshot_e0e29121_0.png diff --git a/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyPreviews.kt b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyPreviews.kt new file mode 100644 index 000000000..adb8b06c3 --- /dev/null +++ b/client/family/core/public/src/commonMain/kotlin/com/plusmobileapps/chefmate/family/core/ui/FamilyPreviews.kt @@ -0,0 +1,121 @@ +package com.plusmobileapps.chefmate.family.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.plusmobileapps.chefmate.family.core.FamilyBloc +import com.plusmobileapps.chefmate.family.data.Family +import com.plusmobileapps.chefmate.family.data.FamilyMember +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Fake [FamilyBloc] for previews and screenshot tests. Public so `client/ui/screenshot-test` can + * reuse it; all handlers are no-ops. + */ +private class PreviewFamilyBloc(model: FamilyBloc.Model) : FamilyBloc { + override val state: StateFlow = MutableStateFlow(model) + + override fun onBack() = Unit + + override fun onSignInClicked() = Unit + + override fun onSignUpClicked() = Unit + + override fun onNewFamilyNameChanged(name: String) = Unit + + override fun onCreateFamilyClicked() = Unit + + override fun onRenameClicked() = Unit + + override fun onEditingNameChanged(name: String) = Unit + + override fun onRenameConfirmed() = Unit + + override fun onRenameCancelled() = Unit + + override fun onInviteEmailChanged(email: String) = Unit + + override fun onInviteClicked() = Unit + + override fun onRemoveMemberClicked(memberId: String) = Unit + + override fun onConfirmRemoveMember() = Unit + + override fun onDismissRemoveMember() = Unit + + override fun onLeaveFamilyClicked() = Unit + + override fun onDeleteFamilyClicked() = Unit + + override fun onConfirmFamilyAction() = Unit + + override fun onDismissFamilyAction() = Unit +} + +/** Signed out — the create form is hidden behind a sign-in prompt. */ +val previewFamilySignedOutBloc: FamilyBloc = + PreviewFamilyBloc(FamilyBloc.Model(isLoading = false, isSignedIn = false)) + +/** Signed in but not in a family yet. */ +val previewFamilyEmptyBloc: FamilyBloc = PreviewFamilyBloc(FamilyBloc.Model(isLoading = false)) + +/** In a family the user owns: rename, invite, remove, and delete are all available. */ +val previewFamilyOwnerBloc: FamilyBloc = + PreviewFamilyBloc( + FamilyBloc.Model( + isLoading = false, + family = Family.Sample, + members = FamilyMember.Samples.toImmutableList(), + isOwner = true, + ) + ) + +/** In a family someone else owns: read-only membership plus "Leave family". */ +val previewFamilyMemberBloc: FamilyBloc = + PreviewFamilyBloc( + FamilyBloc.Model( + isLoading = false, + family = Family.Sample.copy(isOwnedByCurrentUser = false), + members = FamilyMember.Samples.toImmutableList(), + isOwner = false, + ) + ) + +/** Owner mid-rename, with the inline name field showing. */ +val previewFamilyRenamingBloc: FamilyBloc = + PreviewFamilyBloc( + FamilyBloc.Model( + isLoading = false, + family = Family.Sample, + members = FamilyMember.Samples.toImmutableList(), + isOwner = true, + editingName = "The Hendersons", + ) + ) + +@Preview +@Composable +internal fun FamilySignedOutPreview() { + ChefMateTheme { FamilyScreen(bloc = previewFamilySignedOutBloc, modifier = Modifier) } +} + +@Preview +@Composable +internal fun FamilyEmptyPreview() { + ChefMateTheme { FamilyScreen(bloc = previewFamilyEmptyBloc, modifier = Modifier) } +} + +@Preview +@Composable +internal fun FamilyOwnerPreview() { + ChefMateTheme { FamilyScreen(bloc = previewFamilyOwnerBloc, modifier = Modifier) } +} + +@Preview +@Composable +internal fun FamilyMemberPreview() { + ChefMateTheme { FamilyScreen(bloc = previewFamilyMemberBloc, modifier = Modifier) } +} diff --git a/client/ui/screenshot-test/build.gradle.kts b/client/ui/screenshot-test/build.gradle.kts index 0dceed209..f7efd1831 100644 --- a/client/ui/screenshot-test/build.gradle.kts +++ b/client/ui/screenshot-test/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(project(":client:meal:core:public")) implementation(project(":client:meal:core:impl")) implementation(project(":client:meal:data:public")) + implementation(project(":client:family:core:public")) implementation(project(":client:notifications:public")) implementation(project(":client:notifications:impl")) implementation(project(":client:onboarding:public")) diff --git a/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTest.kt b/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTest.kt new file mode 100644 index 000000000..28856e5ed --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTest/kotlin/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTest.kt @@ -0,0 +1,63 @@ +package com.plusmobileapps.chefmate.ui.screenshot + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.plusmobileapps.chefmate.family.core.ui.previewFamilyEmptyBloc +import com.plusmobileapps.chefmate.family.core.ui.previewFamilyMemberBloc +import com.plusmobileapps.chefmate.family.core.ui.previewFamilyOwnerBloc +import com.plusmobileapps.chefmate.family.core.ui.previewFamilyRenamingBloc +import com.plusmobileapps.chefmate.family.core.ui.previewFamilySignedOutBloc +import com.plusmobileapps.chefmate.ui.Content +import com.plusmobileapps.chefmate.ui.theme.ChefMateTheme + +@PreviewTest +@Preview(showBackground = true, heightDp = 800) +@Composable +fun FamilyOwnerLightScreenshot() { + ChefMateTheme { previewFamilyOwnerBloc.Content() } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun FamilyOwnerDarkScreenshot() { + ChefMateTheme(darkTheme = true) { previewFamilyOwnerBloc.Content() } +} + +/** A non-owner sees the member list and "Leave family", but no invite or rename controls. */ +@PreviewTest +@Preview(showBackground = true, heightDp = 800) +@Composable +fun FamilyMemberScreenshot() { + ChefMateTheme { previewFamilyMemberBloc.Content() } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 800) +@Composable +fun FamilyEmptyScreenshot() { + ChefMateTheme { previewFamilyEmptyBloc.Content() } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun FamilyEmptyDarkScreenshot() { + ChefMateTheme(darkTheme = true) { previewFamilyEmptyBloc.Content() } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 800) +@Composable +fun FamilySignedOutScreenshot() { + ChefMateTheme { previewFamilySignedOutBloc.Content() } +} + +@PreviewTest +@Preview(showBackground = true, heightDp = 800) +@Composable +fun FamilyRenamingScreenshot() { + ChefMateTheme { previewFamilyRenamingBloc.Content() } +} diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyDarkScreenshot_907cdd58_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyDarkScreenshot_907cdd58_0.png new file mode 100644 index 000000000..d4cbc795f --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyDarkScreenshot_907cdd58_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6db9ac821c802e0d43ccaf28507832e3c2cac81e9d774f415165171deab80f0c +size 53392 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyScreenshot_e0e29121_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyScreenshot_e0e29121_0.png new file mode 100644 index 000000000..4d104011f --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyEmptyScreenshot_e0e29121_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0be4a1894046234b25208156aaf96786dc4072779b2f155d749b33b7e3f8ffa0 +size 52848 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyMemberScreenshot_e0e29121_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyMemberScreenshot_e0e29121_0.png new file mode 100644 index 000000000..f4bccf416 --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyMemberScreenshot_e0e29121_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46c886816f2439018641f171fdf21383ba045fcfddd8fb10a9b643bc9dee6d22 +size 61051 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerDarkScreenshot_907cdd58_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerDarkScreenshot_907cdd58_0.png new file mode 100644 index 000000000..c09ebac90 --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerDarkScreenshot_907cdd58_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83ef0b85a3ac9ef7da3c8209028bba5902f4bd08ed6acda8cfc3e851bbeeebb0 +size 83993 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerLightScreenshot_e0e29121_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerLightScreenshot_e0e29121_0.png new file mode 100644 index 000000000..c64426e23 --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyOwnerLightScreenshot_e0e29121_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84bd067524f88ed3af166d03d2e4c1f40fc5450aa37394e6d04ae045b7226615 +size 82296 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyRenamingScreenshot_e0e29121_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyRenamingScreenshot_e0e29121_0.png new file mode 100644 index 000000000..d51cec39e --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilyRenamingScreenshot_e0e29121_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a87243e4e227f128b6d7ec5c3feccf0ea9fbea8a3e46434838d7e2738fcbe9b7 +size 91198 diff --git a/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilySignedOutScreenshot_e0e29121_0.png b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilySignedOutScreenshot_e0e29121_0.png new file mode 100644 index 000000000..dec4bdab6 --- /dev/null +++ b/client/ui/screenshot-test/src/screenshotTestDebug/reference/com/plusmobileapps/chefmate/ui/screenshot/FamilyScreenshotTestKt/FamilySignedOutScreenshot_e0e29121_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1f214473a9862853da1b368f212709e86fc2f1c02ecefdf817df37b1919aa93 +size 47602 From 5c25ff2a1195842dbd11cc0d88d26f6709113c72 Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:16:17 -0700 Subject: [PATCH 8/9] test(family): add the More tab -> Family navigation flow Walks the real route a user takes and asserts a fresh account lands on the create form rather than a member list. Co-Authored-By: Claude Opus 5 --- .../chefmate/tests/FamilyNavigationUiTest.kt | 21 +++++++++++++++++++ .../chefmate/settings/robots/MoreRobot.kt | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/FamilyNavigationUiTest.kt diff --git a/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/FamilyNavigationUiTest.kt b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/FamilyNavigationUiTest.kt new file mode 100644 index 000000000..46aee876c --- /dev/null +++ b/client/composeApp/src/commonTest/kotlin/com/plusmobileapps/chefmate/tests/FamilyNavigationUiTest.kt @@ -0,0 +1,21 @@ +package com.plusmobileapps.chefmate.tests + +import androidx.compose.ui.test.ExperimentalTestApi +import com.plusmobileapps.chefmate.family.robots.family +import com.plusmobileapps.chefmate.harness.runRootBlocTest +import com.plusmobileapps.chefmate.recipe.bottomnav.robots.bottomNav +import com.plusmobileapps.chefmate.settings.robots.more +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class FamilyNavigationUiTest { + + @Test + fun opening_family_from_more_tab_shows_the_screen() = runRootBlocTest { + bottomNav().clickMoreTab() + more().awaitDisplayed().clickFamilyRow() + + // A fresh test user isn't in a family, so the screen opens on the create form. + family().awaitDisplayed().assertDisplayed().assertCreateFormShown() + } +} diff --git a/client/settings/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/robots/MoreRobot.kt b/client/settings/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/robots/MoreRobot.kt index bfb386838..f4fb9014d 100644 --- a/client/settings/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/robots/MoreRobot.kt +++ b/client/settings/impl-robots/src/commonMain/kotlin/com/plusmobileapps/chefmate/settings/robots/MoreRobot.kt @@ -32,6 +32,8 @@ class MoreRobot(private val test: ComposeUiTest) { fun clickNotificationsRow(): MoreRobot = clickRow("Notifications") + fun clickFamilyRow(): MoreRobot = clickRow("Family") + private fun clickRow(label: String): MoreRobot = apply { test.waitUntilExactlyOneExists(hasText(label) and onScreen) test.onNode(hasText(label) and onScreen).performClick() From 5a2193c0578617dee38c420d6fe533587854379b Mon Sep 17 00:00:00 2001 From: Andrew Steinmetz Date: Thu, 13 Aug 2026 23:16:17 -0700 Subject: [PATCH 9/9] docs: cover all three collaboration scopes The doc was scoped to grocery lists and is now the reference for three overlapping models, so it opens with what each scope grants and closes with a Family section covering the one-family-per-user index, why the local tables aren't offline-first, and why the client reads through current_family(). Co-Authored-By: Claude Opus 5 --- docs/collaboration.md | 111 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/docs/collaboration.md b/docs/collaboration.md index 7949085e8..e309c3988 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -1,10 +1,21 @@ -# Grocery List Collaboration +# Collaboration -This document describes the architecture for grocery-list collaboration in Chef Mate. -(Recipe sharing is handled separately by recipe-book collaboration — see the `recipebook` -modules.) +Chef Mate has three sharing scopes, which coexist rather than replacing one another: -## Overview +| Scope | Grants access to | Roles | Where it lives | +|---|---|---|---| +| **Grocery list** | one list and its items | owner / editor / viewer | `grocery_list_members` — the bulk of this document | +| **Recipe book** | one book and its recipes | owner / editor / viewer | `recipe_book_members` — see the `recipebook` modules | +| **Family** | many grocery lists, many recipe books, and one meal plan | owner / member | `families` + `family_members` — see [Family](#family) | + +A user may belong to at most one family, and a family sits *alongside* per-entity sharing: you can +still share a single list or book with someone outside your family. + +## Grocery list collaboration + +Everything from here to [Family](#family) describes the grocery-list scope. It came first and is +the most complete implementation — realtime, a local member cache, permission gating — so it's the +template the other scopes follow. Collaboration lets a user share a grocery list with others by email. Each list has role-based access control (Owner, Editor, Viewer) enforced at both the UI and database levels via Supabase @@ -199,3 +210,93 @@ The existing offline-first sync is extended for collaboration: | Edit BLoC | `client/grocery/core/public/.../edit/EditGroceryListBloc.kt` | | Edit screen | `client/grocery/core/impl/.../edit/ui/EditGroceryListScreen.kt` | | DI bindings | `client/database/core/.../di/DatabaseComponent.kt` | + +--- + +## Family + +A **family** is a group of accounts that share content across all three domains at once: many +grocery lists, many recipe books, and a single meal plan. It exists so meal planning can be shared +without bolting a third per-entity member table onto `meal_plans`. + +It's reachable from one row on the More tab, below Notifications and behind the same gate — a +family is keyed on account emails, so it's hidden for signed-out and anonymous users. + +### Phasing + +Phase 1 (shipped) is the group and its invite flow only. The `family_id` columns that actually +scope the three domains land in later phases: + +- **Phase 2** — `family_id` on `grocery_lists` and `recipe_books`, plus a "Share with family" + toggle. No new policies: the existing `can_access_*` / `can_edit_*` helpers gain an + `OR family_id = current_family_id()` clause and every policy already delegates to them. +- **Phase 3** — `family_id` on `meal_plans`, realtime for the shared calendar, and auto-sharing a + scheduled recipe into the family's recipe book so every member can actually open it. + +### Roles + +| Role | Can | +|---|---| +| **Owner** | Everything a member can, plus invite, remove members, rename, and delete the family | +| **Member** | Read and edit everything shared with the family | + +Only two roles, unlike the other scopes' three. A family implies trust — the distinction that +matters is who administers the group, not who may edit. + +### One family per user + +Enforced by the database, not the client: + +```sql +CREATE UNIQUE INDEX idx_fm_one_accepted_family_per_user + ON family_members (user_id) + WHERE status = 'accepted' AND user_id IS NOT NULL; +``` + +Any number of *pending* invites are fine; accepting a second one fails with SQLSTATE 23505. The +repository translates that into `AlreadyInFamilyException`, and the UI tells the user to leave their +current family first. `current_family_id()` can therefore return a single unambiguous value, which +is what every Phase 2/3 policy compares against. + +### Reads are cached, writes are remote-first + +Unlike `GroceryList` / `RecipeBook`, the local `Family` and `FamilyMember` tables carry no +`clientId` / `isDirty` / `getUnsynced` columns — they're read caches, not offline-first sync +targets. Membership is inherently a server concept: creating a family offline could collide with an +invite accepted on another device, and the one-family rule can only be arbitrated by the database. +So mutations go remote-first and throw, while reads come off the cache and render offline. + +The middle ground matters: grocery caches its members locally and can expose a `Flow`, while +recipe-book collaboration went remote-only and can only offer one-shot `suspend` reads. Family +follows grocery. + +### `current_family()`, not `SELECT * FROM families` + +The `families_invitees_select` policy deliberately lets a *pending* invitee read the family row they +were invited to, so the invite card can show its name. A blanket `select()` would therefore pull an +unjoined family into the local cache — the same over-fetch shape as issue #487. The client goes +through the `current_family()` RPC instead, which filters on `current_family_id()`. + +### Invites + +Identical mechanism to the other two scopes, so nothing new was needed end to end: the owner +inserts a `family_members` row, an `AFTER INSERT` trigger fires the same `notify_invite_email()` +function (extended with a `family` kind), and the invitee sees it in the in-app Notifications list +as `AppNotification.FamilyInvite`. There is **no invite deep link** — the email points at +`/notifications`, which the app already handles, so this needed no new Android `pathPrefix` and no +AASA change in the `chefmate-site` repo. + +### Family key files + +| Concern | Path | +|---------|------| +| Supabase migration | `supabase/migrations/20260813_add_families.sql` | +| SQLDelight migration | `client/database/core/src/commonMain/sqldelight/.../database/11.sqm` | +| Local tables | `client/database/core/src/commonMain/sqldelight/.../Family.sq`, `FamilyMember.sq` | +| Repository contract | `client/family/data/public/.../FamilyRepository.kt` | +| Repository | `client/family/data/impl/.../FamilyRepositoryImpl.kt` | +| Remote data source | `client/family/data/impl/.../remote/SupabaseFamilyRemoteDataSource.kt` | +| BLoC | `client/family/core/public/.../FamilyBloc.kt` | +| Screen | `client/family/core/public/.../ui/FamilyScreen.kt` | +| Notification kind | `client/notifications/data/public/.../AppNotification.kt` | +| Settings row | `client/settings/public/.../ui/SettingsScreen.kt` |