SP‑D — People & Access Admin Page — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Consolidate all member/people admin (roster + roles/owner + consents + invites + member↔account linking + child-approvals + add-adult) into ONE two-tab People & Access admin page; turn the main-shell Household tab into a read-only family view; add an admin Households management tab (join/create) while keeping fast switching universal on More.
Architecture: Rebuild the existing GovernancePage (route GovernanceRoute unchanged — no rename, no codegen) into a DsSegmented<PeopleAccessTab> two-tab page (Members / Households), mirroring ManagePage. The page provides BOTH RoleOwnerBloc (roles/owner/consent) and MembersBloc (roster/invites/child-approvals/add) via MultiBlocProvider — no bloc merge (see Bloc note). The Household tab (HouseholdPage) keeps MembersBloc but strips the admin affordances from its widget tree → read-only roster + family goals + tailored view.
Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm flutter test for the app). bloc/cubit; auto_route; the design system (DsSegmented, DsSection, DsAppBackdrop); the flow-test harness (flowTest + MocksContainer).
Global Constraints
- One data path: Bloc → Repository → Client facade → Service → Adapter. Presentation NEVER imports drift/supabase.
- Typed errors ONLY:
on <SpecificException>— never barecatch, neverError. Reuse the existing_runGuardedpattern (mapsDomainRuleException→themed failure dialog). - No new authorization / no server change: SP‑D is a pure client-side UI/IA consolidation. Roles/consents/invites are already server-authoritative. NO migration, NO RLS/capability change. If a genuine server gap surfaces, STOP and flag it — do not add authz.
- Route stays
GovernanceRoute(path/governance, guards[_mustChangeGuard, AdminGuard]) — do NOT rename it (avoidsrouter.gr.dartregen). Only the page class content + title become "People & Access". (Route-name is a cosmetic follow-up.) - Bloc note (deliberate deviation from spec's "single bloc"): the People page co-locates
RoleOwnerBloc+MembersBlocviaMultiBlocProvider; the Household tab renders its ownMembersBlocread-only. A full single-bloc merge is DEFERRED (the two blocs are large —MembersBlocalso owns family goals + tailored — and merging is high-risk for no user-facing gain). The duplication is now co-located on one page, not spread across two. - FVM only; no brand strings; all copy via
Strings; flow tests scoped to EPIC + FEATURE SET (ONE flowTest, multiple stories). Baselines must not drop (app ~838, SDK ~1214).
Reference anchors (verified — implementers re-read these)
GovernancePageapp/lib/inside/routes/authenticated/governance/page.dart:wrappedRoute(23-31) providesRoleOwnerBloc(..)..add(RoleOwnerStarted());build(34-183) =DsAppBackdrop→Scaffold→AppBar(Strings.governanceTitle)→MultiBlocListener(actionFailure dialog +GovernancePage.consentRecordedSnackbar)→BlocBuilder→ready =ColumnofDsSection(Strings.governanceTitle)(rows_GovernanceRow186-250, tap→showRoleOwnerEditorSheet) +_ConsentSection(258-392,GovernancePage.consentChip_<id>/consentButton_<id>).RoleOwnerBlocapp/lib/inside/blocs/governance/role_owner_bloc.dart: eventsRoleOwnerStarted,RoleOwnerRoleSet(memberId, Set<MemberRole>),RoleOwnerOwnerToggled(memberId, grant),RoleOwnerTransferred(fromMemberId, toMemberId),RoleOwnerMemberRemoved(memberId),RoleOwnerConsentCaptured(memberId, ConsentMethod),RoleOwnerConsentRevoked(memberId). State (105-172):status(RoleOwnerStatus.initial/loading/ready/loadFailure/working/actionFailure),members,staleConsentMemberIds,actingMember,errorMessage,consentCaptureOutcome._runGuarded(389-435). Verbs:watchMembers,checkConsentCurrency,setRole,grantOwner,revokeOwner,transferOwnership,removeMember,captureConsent,revokeConsent.showRoleOwnerEditorSheet(context, {required member})governance/role_owner_editor_sheet.dart: capturesRoleOwnerBloc, re-injects; renders role toggles (RoleOwnerEditor.memberBase/adminToggle/helperToggle, admin/helper disabled when!isLinked),RoleOwnerEditor.linkHint,RoleOwnerEditor.ownerSwitch,RoleOwnerEditor.transferButton(owner only),RoleOwnerEditor.removeButton(danger).MembersBlocapp/lib/inside/blocs/household/members_bloc.dart: depsHouseholdRepository, PlacesRepository, GoalsRepository, CurrentMemberRepository, WalletRepository, SelectedMemberRepository?. Admin events used by SP‑D:MembersStarted,MemberEditorOpened({member?}),MemberEditorSaved(+ the editor field events),MemberInviteResent(id),MemberInviteRevoked(id),MemberAccountInviteSent({memberId,email,note?}),MemberAccountInviteResent(id),MemberChildCodeIssued(id),AdminCreateAdultRequested({displayName,kind,email?}),ChildApproved({memberId,method}),ChildDeclined(id),ChildConvertedToAdult(id). State:members,pendingChildren,isParentalViewer,isTailored,accountInviteToken/Attempt,coParentInviteToken/MemberId/Attempt,childAttachCode/Attempt,adminCreateTempPassword/Attempt, editor fields, family-goals fields, tailored fields. Verbs incl.watchMembers,pendingConsentChildren,addMember,updateMember,inviteCoParent,inviteMember,issueChildAttachCode,adminCreateAdult,captureConsent,declineChild,convertPendingChildToAdult,resendInvite,revokeInvite.HouseholdPageapp/lib/inside/routes/authenticated/household/page.dart:wrappedRoute(32-47)MembersBloc(6 deps)..add(MembersStarted()).build(50-267):TopLevelHeader(actions: state.isTailored ? [] : [governanceButton, addMemberButton, createAdultButton])(buttons 178-190+); master body =listChildrosterListView(_MemberTile270-338 tap→MemberProfileRoute,_InvitedMemberTile344-384 tap→showInviteActionsSheet) +ChildApprovalsSection(state)(parental only) +FamilyGoalsSection(state); tailored body =_TailoredMemberView+FamilyGoalsSection.ManagePagetab scaffoldapp/lib/inside/routes/authenticated/manage/page.dart:enum ManageTab {..}(21);wrappedRouteMultiBlocProvider (39-73,lazy:false, seeded);_ManagePageStateStatefulWidgetManageTab _tab(79);DsSegmented<ManageTab>(key:'ManagePage.tabs', value:_tab, onChanged: setState, segments:[DsSegment(value,label)])(108-145);_body()exhaustiveswitch(_tab)(147-158);DsAppBackdrop→Scaffold→AppBar.- AdminHub row
admin/page.dart:73-78_AdminRow(rowKey:'AdminHubPage.rolesOwnersEntry', title:Strings.governanceTitle, onTap:→GovernanceRoute). Household shieldhousehold/page.dart:178-190IconButton(key:'MembersPage.governanceButton', →GovernanceRoute). - Routing
app/lib/inside/routes/router.dart:287-308:/governance=[_mustChangeGuard, AdminGuard(getCurrentMember)].AdminGuard.canAccessAdmin(member) = member.owner || can(member, Capability.manageRoles)(guards/admin_guard.dart:38). - Child-approval
members/child_approvals_section.dart(ChildApprovalsSection({state}), tilesChildApprovalsSection.tile_<id>→showChildApprovalSheet) +members/child_approval_sheet.dart(ChildApprovalSheet.agreeButton→ChildApproved/ChildConvertedToAdultif age≥13;ChildApprovalSheet.declineButton→ChildDeclined). showInviteActionsSheet(context, {member})members/invite_actions_sheet.dart:InviteActionsSheet.resendButton→MemberInviteResent→showCoParentCodeSheet;InviteActionsSheet.revokeButton→MemberInviteRevoked.- Link/add sheets:
members/account_invite_sheet.dartshowInviteToAccountSheet(context, {member});members/child_signup_code_sheet.dartshowChildSignupCodeSheet(context, {member});members/create_adult_sheet.dartshowCreateAdultSheet(context);members/member_editor_sheet.dartshowMemberEditorSheet(context, {member});members/co_parent_code_sheet.dartshowCoParentCodeSheet(...). - Households source:
HouseholdRepository.listMyHouseholds(authUserId); the More switchermore/widgets/household_switcher_sheet.dart+more/join_with_code_sheet.dartshowJoinWithCodeSheet+SetupRoute(isCreatingAdditional:true).MoreBlocloadslistMyHouseholds(resolveauthUserIdviaCurrentMemberRepository.current()?.authUserId, never the lens) — mirror it. - Flow-test templates:
app/test/flows/governance_test.dart(6 flowTests) +app/test/flows/members_test.dart;MocksContainerapp/test/util/mocks/mocked_app.dart. Existing tests to update:governance_test.dart,governance_reconsent_chip_test.dart,members_test.dart,admin_guard_test.dart,widget/governance_guards_test.dart.
Task 1: Scaffold the two-tab People & Access page (Members = existing governance content)
Files:
- Modify:
app/lib/inside/routes/authenticated/governance/page.dart(rebuild into the two-tab page) - Modify:
app/lib/inside/i18n/strings.dart(peopleAccessTitle,peopleTabMembers,peopleTabHouseholds) - Modify:
app/lib/inside/routes/authenticated/admin/page.dart(row title →Strings.peopleAccessTitle; still routes toGovernanceRoute) - Test:
app/test/flows/governance_test.dart(extend — content now under the Members tab)
Interfaces: Produces enum PeopleAccessTab { members, households }; GovernancePage → StatefulWidget with a DsSegmented<PeopleAccessTab> + MultiBlocProvider(RoleOwnerBloc + MembersBloc) in wrappedRoute.
-
Step 1: Rebuild
GovernancePagescaffold — mirrorManagePage.wrappedRoute→MultiBlocProvider(providers: [BlocProvider(create: (_) => RoleOwnerBloc(..)..add(RoleOwnerStarted())), BlocProvider(create: (_) => MembersBloc(..all 6 deps..)..add(MembersStarted()))], child: this). Make it aStatefulWidgetwithPeopleAccessTab _tab = PeopleAccessTab.members.build:DsAppBackdrop→Scaffold→AppBar(Strings.peopleAccessTitle)→Column([Padding(DsSegmented<PeopleAccessTab>(key:'PeoplePage.tabs', value:_tab, onChanged:(t)=>setState(()=>_tab=t), segments:[DsSegment(value:members,label:Strings.peopleTabMembers), DsSegment(value:households,label:Strings.peopleTabHouseholds)])), Expanded(_body())])._body()exhaustiveswitch(_tab):members→const _MembersTab()(move the currentMultiBlocListener+BlocBuilder+twoDsSections verbatim into_MembersTab, still driven byRoleOwnerBloc);households→const _HouseholdsTab()stub (Task 4). -
Step 2: Add strings + re-point AdminHub row —
peopleAccessTitle='People & Access',peopleTabMembers='Members',peopleTabHouseholds='Households'.admin/page.dart:75title →Strings.peopleAccessTitle(onTap unchanged →GovernanceRoute). -
Step 3: Test — extend
governance_test.dart: page rendersDsSegmented(PeoplePage.tabs), defaults to Members, existing member/consent rows render under Members. Adjust existing flowTests to the tabbed structure.cd app && fvm flutter test test/flows/governance_test.dart→ then full suite; fix scaffold breakage. -
Step 4: Commit —
feat(sp-d): People & Access two-tab page scaffold (Members = governance content)
Task 2: Members tab — per-member sheet gains account-link + child-code actions
Files:
- Modify:
app/lib/inside/routes/authenticated/governance/role_owner_editor_sheet.dart - Test:
app/test/flows/governance_test.dart(or a newpeople_members_test.dart)
Design: the per-member sheet (showRoleOwnerEditorSheet) gains the member↔account LINK actions currently on the Household-tab member editor: unlinked adult → Invite to account (showInviteToAccountSheet); unlinked child → Generate sign-up code (showChildSignupCodeSheet). Those are MembersBloc flows (co-located on the page), so capture the MembersBloc before opening them (they re-inject it).
-
Step 1: Extend the sheet — below role/owner/remove: if
member.kind.isAdult && member.authUserId == null && member.status != MemberStatus.invited→DsButton(key:'RoleOwnerEditor.inviteAccountButton')→Navigator.popthenshowInviteToAccountSheet(context, member: member); ifmember.kind == MemberKind.child && member.authUserId == null→DsButton(key:'RoleOwnerEditor.childCodeButton')→showChildSignupCodeSheet(context, member: member). Capturecontext.read<MembersBloc>()before popping (mirror the Household-tab editor's capture pattern). -
Step 2: Test — unlinked adult → invite-account button shows + opens the sheet; unlinked child → child-code button; linked member → neither (roles enabled). RED (buttons absent) → implement → GREEN. Full suite.
-
Step 3: Commit —
feat(sp-d): per-member sheet folds in account-link + child-code actions
Task 3: Members tab — Add actions + pending queue (moved off the Household tab)
Files:
- Modify:
app/lib/inside/routes/authenticated/governance/page.dart(_MembersTabheader + pending section) - Modify:
app/lib/inside/i18n/strings.dart(peoplePendingTitle, add-button labels) - Test:
people_members_test.dart
Design: the Members tab gets (a) an Add header — add-adult (showCreateAdultSheet, admin-gated), add-child (showMemberEditorSheet(member:null)), invite-co-parent (showMemberEditorSheet co-parent add-mode); and (b) a Pending section (with counts): child-approvals (ChildApprovalsSection(state: membersState), reused) + pending co-parent invites (_InvitedMemberTile-equivalent → showInviteActionsSheet). All from the co-located MembersBloc.
-
Step 1: Add header Add actions — a row of
DsButtons (PeoplePage.addAdult/PeoplePage.addChild); gate add-adult byAdminGuard.canAccessAdmin(currentMember)(mirrorhousehold/page.dart:178-190). Wire to the existing sheets. -
Step 2: Pending section —
DsSection(Strings.peoplePendingTitle)shown whenmembersState.isParentalViewer && (pendingChildren.isNotEmpty || invitedMembers.isNotEmpty):ChildApprovalsSection(state: membersState)+ invited co-parent tiles →showInviteActionsSheet. Show a count. -
Step 3: Test — add-adult present for admin / absent for non-admin; pending shows a seeded
pendingConsentchild + invited member; tapping the child → approval sheet →ChildApproved. RED → implement → GREEN. Full suite. -
Step 4: Commit —
feat(sp-d): Members tab add-actions + pending (child-approvals + invites) queue
Task 4: Households tab
Files:
-
Create:
app/lib/inside/blocs/people/households_cubit.dart(+ state) -
Modify:
app/lib/inside/routes/authenticated/governance/page.dart(_HouseholdsTab) -
Modify:
app/lib/inside/blocs/join_household/{cubit,state}.dart(already_member switch-offer — conditional) -
Modify:
app/lib/inside/i18n/strings.dart -
Test:
app/test/unit/blocs/people_households_cubit_test.dart+people_households_test.dart -
Step 1:
PeopleHouseholdsCubit—load()resolvesauthUserId = (await _currentMemberRepository.current())?.authUserId;households = await _householdRepository.listMyHouseholds(authUserId); active id from the current household (getHousehold()?.id) — do NOT add a server verb. State:households,activeHouseholdId,status(loading/ready/error). Typed errors (on Exception→ error; never bare). Test (list, active-mark, empty, error) RED→GREEN. -
Step 2:
_HouseholdsTab—BlocProvider(create: (_) => PeopleHouseholdsCubit(..)..load()); render each household (PeoplePage.household_<id>, active check) + "Join a household" (PeoplePage.joinHousehold→showJoinWithCodeSheet(context)) + "Create another" (PeoplePage.createHousehold→router.push(SetupRoute(isCreatingAdditional:true))). StringspeopleHouseholdsJoin/peopleHouseholdsCreate. -
Step 3:
already_memberswitch-offer (deferred SP‑B fold-in — CONDITIONAL) — ONLY ifpeek_invite'salready_memberhousehold id already reaches the app cheaply: inJoinHouseholdCubit, onInviteAcceptException(alreadyMember)emitJoinAlreadyMember(householdId, householdName)(peek returns the household); the sheet shows a "Switch to it" button →setActiveHousehold+replaceAll(MainShell). If the exception does not carry the household id, KEEP the plainJoinFailure(alreadyMember)and mark this deferred — do NOT widen the SDK exception/RPC just for this. Test whichever path is built. -
Step 4: Test + commit — households list + join/create routing;
already_memberpath (switch-offer OR documented-deferred).feat(sp-d): Households management tab (list + join/create)
Task 5: Household tab → read-only family view
Files:
-
Modify:
app/lib/inside/routes/authenticated/household/page.dart -
Test:
app/test/flows/members_test.dart(update — admin gone) -
Step 1: Strip admin from
TopLevelHeader— remove the three action buttons (MembersPage.governanceButton,MembersPage.addMemberButton,MembersPage.createAdultButton) from the master-viewactions:(→actions: const []or non-admin only). The roster_MemberTilestill taps toMemberProfileRoute(read-only, unchanged). -
Step 2: Remove
ChildApprovalsSectionfrom the master body (moved to People, Task 3). KEEPlistChildroster +FamilyGoalsSection+ the tailored branch (_TailoredMemberView+FamilyGoalsSection) exactly. -
Step 3: Test — admin master view: governance/add/create buttons ABSENT; roster + family goals render; member tap →
MemberProfileRoute;ChildApprovalsSectionabsent. Tailored (child) view unchanged. Updatemembers_test.dartstories that drove admin via the Household tab (assert absence; admin now via People page). RED → implement → GREEN. -
Step 4: Commit —
feat(sp-d): Household tab is a read-only family view (admin moved to People & Access)
Task 6: Feature-set flow test + existing-test fallout + full green
Files:
- Create:
app/test/flows/people_access_test.dart - Modify: existing
governance_test.dart/members_test.dart/admin_guard_test.dart/governance_guards_test.dartstories assuming the OLD structure
CRITICAL — existing-test fallout (SP‑B lesson): moving admin off the Household tab + tabbing the governance page WILL break tests that drove admin from the Household-tab header or asserted the flat governance page. These are SP‑D consequences, NOT pre-existing — repoint them here. Do NOT call any failure "pre-existing" without proving it failed at the SP‑D base commit.
-
Step 1:
people_access_test.dart— ONEflowTest('people_access', …)under one EPIC; stories asscreenshotsegments: manage-a-member (People → Members → member sheet → toggle admin + owner + capture consent), add-adult (Members → Add adult → temp-password result), switch-and-join (Households tab → list + Join). Model ongovernance_test.dart+household_switch_test.dart; stubMocksContainer(watchMembers,pendingConsentChildren,setRole,grantOwner,captureConsent,adminCreateAdult,listMyHouseholds). -
Step 2: Repoint fallout — full suite; for each failure prove it's an SP‑D consequence, then repoint (governance assertions → Members tab; Household-tab admin stories → People page / assert-absent). Keep intent; don't weaken.
-
Step 3: HARD GATE —
cd app && fvm flutter test→ 0 failures, passed not dropped vs ~838. Report the exact+N ~M -Kline. -
Step 4: Commit —
test(sp-d): people-access feature-set flowTest + repoint governance/members fallout
Post-plan
-
graphify update .+ commit (chore: graphify update after SP-D). - Whole-branch SP‑D review (opus) — focus: no lost admin capability (every action from the Household tab / flat governance page is reachable on the People page + still authz-gated), typed errors, no orphaned entry points, the read-only Household tab has truly no admin affordance, test fallout genuinely repointed.
- DEPLOY (owner pre-authorized pattern): SP‑D is client-only (NO migration). Push → redeploy both URLs → rebuild the Android APK. (No prod migration; flag if Task 4 needed any server touch — it should not.)
Self-Review notes (author)
- Spec coverage: two-tab page → T1; Members consolidation (roster+roles/owner+consent+link) → T1/T2; add + pending → T3; Households tab + already_member → T4; Household tab read-only → T5; tests → T6.
- Deliberate deviations (flag for review): (1) No bloc merge — co-locate both blocs (spec said "single bloc"); merging is high-risk (
MembersBlocalso owns family goals + tailored); duplication now co-located; merge deferred. (2) Route keptGovernanceRoute(no rename) to avoid codegen regen risk; page content/title become "People & Access". (3)already_memberswitch-offer folds in ONLY if ≤ a field already on the exception; else deferred (no SDK widening). - Type consistency:
PeopleAccessTab {members, households}inDsSegmented+_body();PeopleHouseholdsCubitstable; reused sheet signatures are the verified anchors. - Main risk = test fallout — T6 owns it explicitly.