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/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/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/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/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/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 000000000..8b62e1bc6 Binary files /dev/null and b/client/database/core/src/commonMain/sqldelight/schema/12.db differ 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/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/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/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/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), + ) } 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-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() 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 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 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 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` | 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") 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 $$;