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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/auth/usecase/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ 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)
implementation(projects.client.recipebook.data.public)
}
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }

Expand All @@ -47,6 +49,7 @@ class SignOutUseCaseImplTest {
recipeBookRepository = recipeBookRepository,
categoryRepository = categoryRepository,
groceryAutocompleteRepository = groceryAutocompleteRepository,
familyRepository = familyRepository,
aiChatLocalDataCleaner = aiChatLocalDataCleaner,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions client/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<GroceryDisplayItem>()
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<GroceryDisplayItem>()
setGroceryListContent(deleted, swipeToDeleteEnabled = false)

onNodeWithText("Apples").performTouchInput { swipeLeft() }
waitForIdle()

onNodeWithText("Apples").assertIsDisplayed()
assertTrue(deleted.isEmpty())
}
}

private fun ComposeUiTest.setGroceryListContent(
deleted: MutableList<GroceryDisplayItem>,
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,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Binary file not shown.
13 changes: 13 additions & 0 deletions client/family/core/impl-robots/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading