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
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ class BottomNavBlocImpl(
SettingsBloc.Output.OpenSignIn -> OpenSignIn
SettingsBloc.Output.OpenSignUp -> OpenSignUp
SettingsBloc.Output.OpenManageProfile -> OpenManageProfile
SettingsBloc.Output.OpenMyProfile -> BottomNavBloc.Output.OpenMyProfile
SettingsBloc.Output.OpenNotifications -> BottomNavBloc.Output.OpenNotifications
SettingsBloc.Output.OpenAppSettings -> OpenAppSettings
SettingsBloc.Output.OpenAiChat -> BottomNavBloc.Output.OpenAiChat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ interface BottomNavBloc : BackHandlerOwner, BackClickBloc, ComposeScreen {

data object OpenManageProfile : Output()

data object OpenMyProfile : Output()

data object OpenNotifications : Output()

data object OpenAppSettings : Output()
Expand Down
3 changes: 3 additions & 0 deletions client/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ kotlin {
api(projects.client.recipebook.data.impl)
api(projects.client.recipebook.edit.impl)
api(projects.client.profile.impl)
api(projects.client.profile.data.impl)
api(projects.client.profile.data.public)
api(projects.client.util.impl)
api(projects.client.settings.impl)
api(projects.client.settings.root.impl)
Expand Down Expand Up @@ -171,6 +173,7 @@ kotlin {
implementation(projects.client.settings.implRobots)
implementation(projects.client.settings.root.implRobots)
implementation(projects.client.profile.implRobots)
implementation(projects.client.profile.data.testing)
implementation(projects.client.notifications.implRobots)
implementation(projects.client.onboarding.implRobots)
}
Expand Down
5 changes: 5 additions & 0 deletions client/composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
android:scheme="https"
android:host="chefmate.plusmobileapps.com"
android:pathPrefix="/recipe" />
<!-- Public profiles: https://chefmate.plusmobileapps.com/@handle -->
<data
android:scheme="https"
android:host="chefmate.plusmobileapps.com"
android:pathPrefix="/@" />
</intent-filter>
</activity>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class FakeRecipeRemoteDataSource : RecipeRemoteDataSource {

override suspend fun fetchPublicRecipe(remoteId: String): RemoteRecipe? = null

override suspend fun fetchPublishedRecipes(
profileId: String,
limit: Int,
offset: Int,
): List<RemoteRecipe> = emptyList()

override suspend fun setRecipeCategories(
recipeRemoteId: String,
categoryRemoteIds: Set<String>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.plusmobileapps.chefmate.fakes

import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository
import com.plusmobileapps.chefmate.di.AppScope
import com.plusmobileapps.chefmate.profile.data.ProfileRepository
import com.plusmobileapps.chefmate.profile.data.SocialProfile
import com.plusmobileapps.chefmate.profile.data.impl.SupabaseProfileRepository
import com.plusmobileapps.chefmate.profile.data.testing.FakeProfileRepository
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn

/**
* Replaces [SupabaseProfileRepository] in the test graph, which would otherwise need a real
* [io.github.jan.supabase.SupabaseClient]. Mirrors [TestAuthenticationRepository].
*/
@SingleIn(AppScope::class)
@Inject
@ContributesBinding(scope = AppScope::class, replaces = [SupabaseProfileRepository::class])
class TestProfileRepository(private val fake: FakeProfileRepository = FakeProfileRepository()) :
ProfileRepository by fake {

init {
// Line the fake up with the default authenticated test user, so "my profile" resolves the
// same way it would in the app.
fake.currentUserId = FakeAuthenticationRepository.fakeUser().userId
}

fun addProfile(profile: SocialProfile) = fake.addProfile(profile)

fun profileFor(handle: String): SocialProfile? = fake.profileFor(handle)

/** The default user's own profile, for tests that need them to already have one. */
fun givenOwnProfile(handle: String = "testchef"): SocialProfile =
SocialProfile(
id = fake.currentUserId,
handle = handle,
displayName = "Test Chef",
bio = "I test recipes.",
avatarUrl = null,
)
.also { fake.addProfile(it) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.plusmobileapps.chefmate.tests

import androidx.compose.ui.test.ExperimentalTestApi
import com.plusmobileapps.chefmate.harness.runRootBlocTest
import com.plusmobileapps.chefmate.profile.robots.manageProfile
import com.plusmobileapps.chefmate.profile.robots.profile
import com.plusmobileapps.chefmate.recipe.bottomnav.robots.bottomNav
import com.plusmobileapps.chefmate.settings.robots.more
import kotlin.test.Test

@OptIn(ExperimentalTestApi::class)
class ProfileNavigationUiTest {

@Test
fun opening_my_profile_without_a_handle_shows_the_create_prompt() = runRootBlocTest {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickMyProfileRow()

// No handle claimed yet, so the profile invites the user to create one.
profile().awaitDisplayed().assertEmptyStateShown()
}

@Test
fun create_profile_routes_to_the_editor_to_claim_a_handle() = runRootBlocTest {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickMyProfileRow()

profile().awaitDisplayed().tapCreateProfile()

manageProfile().awaitDisplayed().assertDisplayed()
}

@Test
fun claiming_a_handle_from_the_editor_saves_and_returns() = runRootBlocTest {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickMyProfileRow()
profile().awaitDisplayed().tapCreateProfile()

manageProfile()
.awaitDisplayed()
.setDisplayName("Julia Child")
.setHandle("juliachild")
.setBio("French cooking, demystified.")
// The handle availability check is debounced, so Save unlocks a beat later.
.awaitSaveEnabled()
.tapSave()

// Saving pops back to whatever launched the editor.
profile().awaitDisplayed()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Publishing a recipe to the owner's public profile. Mirrors the server-side
-- `recipes.published_at` column (see the Supabase migration 20260731_add_social_profiles.sql).
--
-- Deliberately separate from isPublic (added in 9.sqm): isPublic means "readable by anyone holding
-- the share link" and is unlisted, while publishedAt means "listed on my profile". Publishing
-- implies isPublic, but not the reverse — otherwise every recipe a user had ever shared by link
-- would appear on their profile the moment they created one.
--
-- NULL (the default, and what every existing row gets) means unpublished. Stored as TEXT to match
-- the createdAt/updatedAt timestamps on this table.
ALTER TABLE Recipe ADD COLUMN publishedAt TEXT;
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ CREATE TABLE Recipe (
isDirty INTEGER AS Boolean NOT NULL DEFAULT 0,
ownerId TEXT,
isPendingDelete INTEGER AS Boolean NOT NULL DEFAULT 0,
isPublic INTEGER AS Boolean NOT NULL DEFAULT 0
isPublic INTEGER AS Boolean NOT NULL DEFAULT 0,
publishedAt TEXT
);

getAll:
Expand All @@ -42,8 +43,8 @@ INSERT INTO Recipe (title, description, ingredients, directions, imageUrl, sourc
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

createWithRemoteId:
INSERT INTO Recipe (title, description, ingredients, directions, imageUrl, sourceUrl, servings, prepTime, cookTime, totalTime, calories, starRating, isFavorite, createdAt, updatedAt, remoteId, clientId, ownerId, isPublic)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO Recipe (title, description, ingredients, directions, imageUrl, sourceUrl, servings, prepTime, cookTime, totalTime, calories, starRating, isFavorite, createdAt, updatedAt, remoteId, clientId, ownerId, isPublic, publishedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(remoteId) DO UPDATE SET
title = excluded.title,
description = excluded.description,
Expand All @@ -61,7 +62,8 @@ ON CONFLICT(remoteId) DO UPDATE SET
updatedAt = excluded.updatedAt,
clientId = excluded.clientId,
ownerId = excluded.ownerId,
isPublic = excluded.isPublic;
isPublic = excluded.isPublic,
publishedAt = excluded.publishedAt;

lastInsertId:
SELECT MAX(id) FROM Recipe;
Expand Down Expand Up @@ -97,6 +99,15 @@ SELECT * FROM Recipe WHERE isPendingDelete = 1 AND remoteId IS NOT NULL;
setPublic:
UPDATE Recipe SET isPublic = ?, isDirty = 1, updatedAt = ? WHERE id = ?;

-- Callers publishing a recipe must also setPublic(true) — a listed recipe has to be readable. The
-- repository sequences the two in one transaction. Unpublishing (publishedAt = NULL) deliberately
-- leaves isPublic alone, so a share link already handed out keeps working.
setPublished:
UPDATE Recipe SET publishedAt = ?, isDirty = 1, updatedAt = ? WHERE id = ?;

getPublished:
SELECT * FROM Recipe WHERE publishedAt IS NOT NULL AND isPendingDelete = 0 ORDER BY publishedAt DESC;

updateRemoteId:
UPDATE Recipe SET remoteId = ?, isDirty = 0 WHERE id = ?;

Expand Down
21 changes: 21 additions & 0 deletions client/profile/data/impl/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
plugins { alias(libs.plugins.kmpLibrary) }

kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.client.profile.data.public)
implementation(projects.client.auth.data.public)
implementation(projects.client.shared)
implementation(libs.supabase.client)
implementation(libs.supabase.auth)
implementation(libs.supabase.postgrest)
}
commonTest.dependencies { implementation(projects.client.profile.data.testing) }
}
}

plusLibrary {
namespace = "com.plusmobileapps.chefmate.profile.data.impl"
enableDi = true
enableTesting = true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.plusmobileapps.chefmate.profile.data.impl

import com.plusmobileapps.chefmate.profile.data.SocialProfile
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Wire shape of a `profiles` row. Matches both the table itself and the column list returned by the
* `get_profile_by_handle` / `get_profile_by_id` RPCs — [publishedRecipeCount] is only populated by
* the latter, since a plain table select has no count to give.
*/
@Serializable
internal data class RemoteProfile(
val id: String,
val handle: String,
@SerialName("display_name") val displayName: String = "",
val bio: String = "",
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("published_recipe_count") val publishedRecipeCount: Long = 0,
) {
fun toSocialProfile(): SocialProfile =
SocialProfile(
id = id,
handle = handle,
displayName = displayName,
bio = bio,
avatarUrl = avatarUrl,
publishedRecipeCount = publishedRecipeCount.toInt(),
)
}

/** Insert/update payload. Omits [RemoteProfile.publishedRecipeCount], which is server-derived. */
@Serializable
internal data class ProfileUpsert(
val id: String? = null,
val handle: String? = null,
@SerialName("display_name") val displayName: String,
val bio: String,
@SerialName("avatar_url") val avatarUrl: String? = null,
)
Loading
Loading