feat(family): add family collaboration (phase 1) - #531
Open
plusmobileapps wants to merge 9 commits into
Open
Conversation
* 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
family_idongrocery_listsandrecipe_booksplus a "Share with family" toggle. No new policies needed: the existingcan_access_*/can_edit_*helpers gain anOR family_id = current_family_id()clause and every policy already delegates to them.family_idonmeal_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/leaveFamilyhit Supabase and surface failures. One-family-per-user can only be arbitrated by the database, andAlreadyInFamilyExceptioncarries 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, soAppNotification.FamilyInviteslots into the existing inbox with no new AndroidpathPrefixand no AASA change in thechefmate-siterepo. 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 ofFamilyViewModelresolved the pending removal withfirstOrNull { 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
enableDatabaseTesting, fake remote drives create → invite → accept)RootBlocTestrouting testsrunRootBlocTestflow: More tab → Family → create formThe 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