Skip to content

feat(family): add family collaboration (phase 1) - #531

Open
plusmobileapps wants to merge 9 commits into
claude/family-local-schemafrom
claude/family-meal-planning-collab
Open

feat(family): add family collaboration (phase 1)#531
plusmobileapps wants to merge 9 commits into
claude/family-local-schemafrom
claude/family-meal-planning-collab

Conversation

@plusmobileapps

Copy link
Copy Markdown
Collaborator

Stack 3/3 — stacked on #530. Review #529 and #530 first; this diff shows only the client work once those are merged.

Closes the "I never added collaboration for meal planning" gap by introducing a family — a group of accounts that shares many grocery lists, many recipe books, and one meal plan, reachable from a single settings row.

This is phase 1 of 3: the group, its membership, and its invite flow. Nothing is actually shared yet.

  • Phase 2family_id on grocery_lists and recipe_books plus a "Share with family" toggle. No new policies needed: the existing can_access_* / can_edit_* helpers gain an OR family_id = current_family_id() clause and every policy already delegates to them.
  • Phase 3family_id on meal_plans, realtime for the shared calendar, and auto-sharing a scheduled recipe into the family's book so every member can actually open it.

Design decisions worth arguing with

Two roles, not three. Grocery lists and recipe books have owner/editor/viewer. A family has owner/member — it implies trust, and the only distinction that matters is who administers the group. That's why the screen has just Owner and Members groups and no role picker on the invite form.

Writes are remote-first and throw. Reads come off the local cache so the screen renders offline, but createFamily / invite / leaveFamily hit Supabase and surface failures. One-family-per-user can only be arbitrated by the database, and AlreadyInFamilyException carries that case so the UI can say "leave your current family first" instead of leaking a raw 23505.

No deep link. Invite emails already point at /notifications, which the app handles, so AppNotification.FamilyInvite slots into the existing inbox with no new Android pathPrefix and no AASA change in the chefmate-site repo. A /family/invite/<token> link would be a genuinely new mechanism — left as a follow-up.

The row is on the More tab, not under Settings. It's account state, alongside Notifications and behind the same signed-in-and-not-anonymous gate, rather than an app preference.

A bug the tests caught

The owner's member row has a deliberately null id (it's synthesized by the RPC, not read from a member row). The first cut of FamilyViewModel resolved the pending removal with firstOrNull { it.id == removingMemberId }, which matched the owner whenever nothing was pending — the remove-confirmation dialog would have opened by itself on the owner. Guarded explicitly, with a comment so it doesn't come back.

Testing

  • 11 repository tests (enableDatabaseTesting, fake remote drives create → invite → accept)
  • 13 BLoC tests
  • 3 new RootBlocTest routing tests
  • A runRootBlocTest flow: More tab → Family → create form
  • 7 snapshots: signed out, no family, owner, member, mid-rename, plus empty and owner in dark

The owner/member snapshot pair is the one to eyeball — it's what proves the admin controls are gated.

Not yet done

End-to-end against two real accounts on the local Supabase stack. The migration in #529 should be applied there before this merges.

🤖 Generated with Claude Code

plusmobileapps and others added 9 commits August 13, 2026 11:29
* feat(grocery): drop the in-field add button from the grocery input

Adding an item is now done with the keyboard's send action on mobile or
Enter on desktop, so the trailing "+" icon button inside the text field
was redundant chrome. Removing it also frees the trailing slot and the
now-unused grocery_add_item string.

Snapshot references for the grocery list screen re-recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(grocery): swipe a grocery row away to delete it on mobile

Swipe towards the trailing edge to reveal a red delete background and
remove the item. Only end-to-start is enabled so the gesture doesn't
fight the Android system back gesture at the leading edge, and the row
picks up an opaque surface background so the red stays hidden until the
row moves.

The trailing delete button stays exactly as it was — the swipe is an
additional way to delete, not a replacement. It's off by default on
desktop, where a stray mouse drag across a row would be an easy way to
lose an item; `swipeToDeleteEnabled` makes that overridable so the
gesture can be tested on every target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(grocery): Done adds whatever is left in the grocery input

Done previously only dismissed the keyboard, so text the user had typed
but not submitted just sat in the field. It now submits that item first
and then dismisses, matching what "Done" implies. An empty (or
whitespace-only) field still just dismisses, as before.

Covered by a robot UI test that types an item, taps Done, and asserts
the row lands in the list and the input clears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(grocery): hide the row delete button where swipe-to-delete exists

On touch platforms the delete button sits right under a thumb on every
row, which makes an accidental tap easy — and now that a row can be
swiped away there's a deliberate gesture to reach for instead. Desktop,
where swipe is off, keeps the button as the only way to delete a single
item.

The sync icon becomes the last thing in the row when the button is
hidden, and it carries none of the padding an IconButton builds in, so
it takes its own inset off the trailing edge.

Snapshot references re-recorded (they render as Android, so the button
is hidden there).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A family is a group of accounts that share content across grocery lists, recipe
books, and the meal plan. This adds the group and membership model plus its
invite flow; the family_id columns that scope the three domains land in later
phases.

Families sit alongside the existing per-entity sharing rather than replacing it
— nothing here touches grocery_list_members or recipe_book_members, so sharing a
single list or book outside the family keeps working.

Structure mirrors 20260425_add_collaboration.sql. Three things worth review:

- The partial unique index on family_members(user_id) WHERE status='accepted' is
  what enforces one family per user. Accepting a second invite fails at the DB
  with 23505 rather than silently corrupting current_family_id().
- The helpers are SECURITY DEFINER because the families and family_members
  policies check each other's tables and would otherwise recurse (42P17) — the
  same failure 20260610_fix_recipe_rls_recursion.sql was written to fix.
- current_family() exists because families_invitees_select deliberately widens
  SELECT to families you've only been invited to, so a blanket select() would
  pull an unjoined family into the client's cache. Same over-fetch shape as #487.

notify_invite_email() gains a 'family' kind rather than a second function, so all
three invite kinds stay on one code path; the edge function now resolves the
parent name and owner through one lookup table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Local cache for the families/family_members tables added in the previous commit.
Nothing consumes the queries yet — the repository that does lands next.

Deliberately not offline-first: unlike GroceryList and RecipeBook these carry no
clientId / isDirty / getUnsynced columns. Membership is inherently a server
concept — creating a family offline could collide with an invite accepted on
another device, and the one-family-per-user rule can only be arbitrated by the
database. So these are read caches refreshed from the server, which is also how
RecipeBookCollaborationRepository treats collaboration state.

Members are cached locally (grocery's model) rather than fetched per-view
(recipe books' model) so the Family screen can expose an observable Flow and
render offline.

Schema goes to version 12; verifyCommonMainDatabaseMigration passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reads (family, members, pending invites) come off the local cache so the screen
renders offline; writes go remote-first and throw, because "one family per user"
can only be arbitrated by the database. AlreadyInFamilyException carries that
case so the UI can say "leave your current family first" instead of surfacing a
raw 23505.

Two roles, not the three grocery lists and recipe books use. A family implies
trust — every member can edit everything shared with it, and the only
distinction that matters is who administers the group. That's why the screen has
just Owner and Members groups, and why there's no role picker on the invite form.

The repository reads the family through the current_family() RPC rather than
selecting from families: RLS deliberately lets a pending invitee read the family
row they were invited to, so a blanket select would cache a family the user
hasn't joined.

Sign-out clears the cache alongside the other repositories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AppNotification.FamilyInvite joins the grocery and recipe-book kinds, so family
invites land in the existing inbox with no new mechanism — the invite email
already points at /notifications, which means no new Android pathPrefix and no
AASA change in the site repo.

Unlike the other two kinds there's no role to show, since every family invite is
for a member.

Accepting while already in a family fails at the database. That's actionable
rather than transient, so it gets its own message telling the user to leave
first, not the generic "try again".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One row, directly below Notifications and behind the same gate — a family is
keyed on account emails, so it's hidden for signed-out and anonymous users. It
sits on the More tab rather than under Settings because it's account state, not
an app preference.

Routes the way Notifications does: SettingsBloc output -> BottomNavBloc ->
RootBlocImpl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the states that differ structurally rather than cosmetically: signed out,
no family yet, owner (rename/invite/remove/delete), member (read-only plus
Leave), and mid-rename. Empty and owner also in dark.

The owner/member pair is the one worth eyeballing on a diff — it's what proves
the admin controls are actually gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Walks the real route a user takes and asserts a fresh account lands on the
create form rather than a member list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc was scoped to grocery lists and is now the reference for three
overlapping models, so it opens with what each scope grants and closes with a
Family section covering the one-family-per-user index, why the local tables
aren't offline-first, and why the client reads through current_family().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant