Catalog Restructure — Phase B: Personalize Earn + Rewards — 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: Make the Earn tab a personal action surface for the signed-in member — their assigned chores shown as a checkable/submittable To-Do (submit happens here, not only on Today) — with the member-switcher defaulting to self; Rewards personalizes for free via the same bloc. (Bounty Join and Rewards Redeem are already wired; the "suggest a chore/reward" relocation is Phase D, NOT here.)
Architecture: All logic is in the page-scoped CatalogBloc (each of Earn/Rewards owns its instance). Add a CatalogState.assignedToMember(id) getter + an EarnFilter.assignedToMe (To-Do) filter; add a CatalogChoreCompletionSubmitted event + handler reusing the existing _choresRepository.submitCompletion + the bloc's existing consent-prompt pattern (NO new repository injection); default the initial browse member to the authenticated member in _onStarted (per-page, no write to the shared lens store); add a check-off affordance to EarnChoreCard.
Tech Stack: Flutter 3.44 / Dart 3.9 (FVM), flutter_bloc, mocktail unit tests + flow_test flow tests, client_sdk models.
Global Constraints
- FVM only:
cd app && fvm flutter test,fvm flutter analyze. Never bare flutter/dart, never node. - One data path; personalization logic in the bloc, not the widget.
- App suite baseline 768 must not drop. After ANY task, run the FULL app suite (
cd app && fvm flutter test), not just scoped files — Phase A had a compile-break hide behind scoped runs. - If you add ANY constructor parameter to a bloc, update EVERY construction site (grep
<BlocName>(acrossapp/+app/test/) in the SAME task. (Phase B's new event needs no new ctor param — keep it that way.) - All copy via
Strings. Reuse DS atoms /CatalogItemCard/EarnChoreCard. Explicitgit add(never-A/.). Do NOT run build_runner unless a@JsonSerializablestate/model field is added; if a new transient state field is added andCatalogStateis@JsonSerializable, annotate@JsonKey(includeToJson:false, includeFromJson:false)(like sibling transient fields) sostate.g.dartis untouched — no build_runner. New GETTERS need no codegen. - Do NOT touch the "suggest/requestChore" relocation or the Household section — that is Phase D.
Key interfaces (from exploration — use verbatim)
CatalogState(state.dart):List<Chore> chores,String? selectedMemberId, gettersexpectations/bounties/canClaimBounty(id)/hasJoined(id)/eligibilityFor(id);HouseholdMember? get selectedMember.Chore.assignedMemberIdsUnion(chore.dart:282) —List<String>; empty = assigned to all.EarnFilter(earn/page.dart:101) ={ all, chores, bounties, claimable }; mapped in_EarnSuccessView._filtered()(earn/page.dart:215).CatalogBlocalready holds_choresRepository,_currentMemberRepository,_selectedMemberRepository;_onStartedseedsselectedMemberIdfrom_selectedMemberRepository.current(bloc.dart:117); consent patternneedsParentalConsent(...)+_emitConsentPrompt(...)exists (bloc.dart:446-466).ChoresRepository.submitCompletion({required String memberId, required String choreId, String? note}) → Future<ChoreSubmission>(chores_repository.dart:52).TodayChoreCompletionSubmitted({required choreId, required memberId})+TodayChoresBloc._onCompletionSubmitted(today_chores/bloc.dart:361) — the reference implementation to mirror.- Unit test harness:
app/test/unit/catalog_bloc_test.dart—build()factory +setUpmocktail stubs +started()helper (bloc.add(CatalogStarted()); await bloc.stream.firstWhere((s)=>s.status==CatalogStatus.loadSuccess));setUpAll(registerClientSdkFallbacks). Flow tests:app/test/flows/earn_test.dart(flowTest<MocksContainer>).
Task 1: CatalogState.assignedToMember getter + EarnFilter.assignedToMe (To-Do)
Files:
- Modify:
app/lib/inside/blocs/catalog/state.dart(new getter) - Modify:
app/lib/inside/routes/authenticated/earn/page.dart(newEarnFiltervalue +_filtered()branch + segment label) - Modify:
app/lib/inside/i18n/strings.dart(filter label + empty-state copy) - Test:
app/test/unit/catalog_bloc_test.dart(getter test)
Interfaces:
-
Produces:
List<Chore> CatalogState.assignedToMember(String memberId)— the member's assigned + unassigned expectations plus bounties they've already joined.EarnFilter.assignedToMe. -
Step 1: Failing getter test in
catalog_bloc_test.dart:
test('assignedToMember returns the member\'s assigned + unassigned expectations, excludes others-only', () async {
// seed: bedExpectation assigned to [kidYoung]; dishesExpectation assigned to [] (all);
// homeworkExpectation assigned to [kidOlder]
final (bloc, state) = await started();
final ids = state.assignedToMember(kidYoung.id).map((c) => c.id);
expect(ids, containsAll([bedExpectation.id, dishesExpectation.id]));
expect(ids, isNot(contains(homeworkExpectation.id)));
await bloc.close();
});
-
Step 2: Run — expect FAIL (
assignedToMemberundefined) Run:cd app && fvm flutter test test/unit/catalog_bloc_test.dart -
Step 3: Implement the getter in
state.dart(place nearexpectations/bounties):
/// The chores this member is on the hook for — their assigned expectations plus
/// household-wide (unassigned) expectations, plus any bounty they have already
/// joined. Bounties they could merely *claim* live in the Joinable filter.
List<Chore> assignedToMember(String memberId) => chores.where((c) {
if (c.kind == ChoreKind.bounty) return hasJoined(c.id);
final union = c.assignedMemberIdsUnion;
return union.isEmpty || union.contains(memberId);
}).toList();
- Step 4: Add the filter in
earn/page.dart: addassignedToMetoEarnFilter(as the FIRST value so it can be the default), and a_filtered()branch:
EarnFilter.assignedToMe => state.assignedToMember(state.selectedMemberId ?? ''),
Add the DsSegmented label (Strings.earnFilterToDo = 'To-Do') and an empty-state string (Strings.earnToDoEmpty = 'Nothing assigned right now.') in strings.dart, wired into the segment list + empty view. (Also relabel the claimable segment copy to "Joinable" if a string exists — cosmetic.)
-
Step 5: Run — expect PASS, then FULL suite: Run:
cd app && fvm flutter test test/unit/catalog_bloc_test.dartthencd app && fvm flutter testExpected: PASS, count ≥ 768. -
Step 6: Commit
git add app/lib/inside/blocs/catalog/state.dart app/lib/inside/routes/authenticated/earn/page.dart app/lib/inside/i18n/strings.dart app/test/unit/catalog_bloc_test.dart
git commit -m "feat: Earn To-Do (assignedToMember getter + assignedToMe filter) (Phase B)"
Task 2: Default the Earn/Rewards browse member to self
Files:
- Modify:
app/lib/inside/blocs/catalog/bloc.dart(_onStartedinitial selection) - Test:
app/test/unit/catalog_bloc_test.dart
Interfaces: Produces: on load, state.selectedMemberId = the authenticated member's id when in the roster (overriding a persisted lens pick), WITHOUT writing to SelectedMemberRepository (per-page only).
- Step 1: Failing test:
test('Catalog defaults the browse member to the authenticated member, ignoring a persisted lens pick', () async {
when(() => selectedMember.current).thenReturn(kidOlder.id); // persisted pick = someone else
when(currentMember.current).thenAnswer((_) async => parent); // signed-in member
final (bloc, state) = await started();
expect(state.selectedMemberId, parent.id);
verifyNever(() => selectedMember.select(any())); // did NOT write to the shared store
await bloc.close();
});
-
Step 2: Run — expect FAIL (currently seeds from
_selectedMemberRepository.current= kidOlder) Run:cd app && fvm flutter test test/unit/catalog_bloc_test.dart -
Step 3: Implement in
bloc.dart_onStarted, where the initialselectedIdis computed (bloc.dart:117): resolve the authenticated member first and prefer it:
final authed = await _currentMemberRepository.current();
final rosterIds = participants.map((m) => m.id).toSet();
final initialSelectedId = (authed != null && rosterIds.contains(authed.id))
? authed.id
: _selectedMemberRepository.current;
Use initialSelectedId as the emitted selectedMemberId. Do NOT call _selectedMemberRepository.select(...). The existing _selectedMemberSubscription still lets the switcher change it afterward.
-
Step 4: Run — expect PASS, then FULL suite (≥ 768). Update any existing catalog test that assumed the persisted-pick default with an authenticated viewer (note in commit body).
-
Step 5: Commit
git add app/lib/inside/blocs/catalog/bloc.dart app/test/unit/catalog_bloc_test.dart
git commit -m "feat: Earn/Rewards default browse member to self (Phase B)"
Task 3: CatalogChoreCompletionSubmitted event + handler
Files:
- Modify:
app/lib/inside/blocs/catalog/events.dart(new event) - Modify:
app/lib/inside/blocs/catalog/bloc.dart(register +_onCompletionSubmitted) - Modify:
app/lib/inside/blocs/catalog/state.dartIF a transient submit/done state is needed (aSet<String> submittingChoreIds,@JsonKey-excluded if the state is@JsonSerializable) - Test:
app/test/unit/catalog_bloc_test.dart
Interfaces:
-
Consumes:
_choresRepository.submitCompletion(memberId:, choreId:); existingneedsParentalConsent/_emitConsentPrompt. -
Produces:
CatalogChoreCompletionSubmitted({required String choreId})(usesstate.selectedMemberIdas the acting member). NO new constructor param onCatalogBloc. -
Step 1: Failing test:
test('CatalogChoreCompletionSubmitted submits the completion for the selected member', () async {
when(() => chores.submitCompletion(memberId: any(named: 'memberId'), choreId: any(named: 'choreId')))
.thenAnswer((_) async => aChoreSubmission);
final (bloc, _) = await started();
final memberId = bloc.state.selectedMemberId!;
bloc.add(CatalogChoreCompletionSubmitted(choreId: bedExpectation.id));
await bloc.stream.firstWhere((s) => s.status == CatalogStatus.loadSuccess);
verify(() => chores.submitCompletion(memberId: memberId, choreId: bedExpectation.id)).called(1);
await bloc.close();
});
(Add a consent-gated case if a child requires consent — mirror the Today consent test.)
-
Step 2: Run — expect FAIL (event undefined) Run:
cd app && fvm flutter test test/unit/catalog_bloc_test.dart -
Step 3: Implement — add
CatalogChoreCompletionSubmittedtoevents.dart; registeron<CatalogChoreCompletionSubmitted>(_onCompletionSubmitted); write_onCompletionSubmittedmirroringTodayChoresBloc._onCompletionSubmitted(today_chores/bloc.dart:361): readstate.selectedMemberId(return if null), run the existingneedsParentalConsent/_emitConsentPromptpre-check for the acting member, thenawait _choresRepository.submitCompletion(memberId: memberId, choreId: event.choreId), mapping errors tosetErrorMessage. IfEarnChoreCardneeds a submit-in-flight signal, addSet<String> submittingChoreIdsto state (JSON-excluded) and flip it around the await. -
Step 4: Run — expect PASS, then FULL suite (≥ 768).
-
Step 5: Commit
git add app/lib/inside/blocs/catalog/events.dart app/lib/inside/blocs/catalog/bloc.dart app/lib/inside/blocs/catalog/state.dart app/test/unit/catalog_bloc_test.dart
git commit -m "feat: CatalogChoreCompletionSubmitted (submit a chore from Earn) (Phase B)"
Task 4: Check-off affordance on EarnChoreCard (the To-Do action)
Files:
- Modify:
app/lib/inside/routes/authenticated/earn/earn_chore_card.dart(submit button on assigned expectations) - Modify:
app/lib/inside/i18n/strings.dart(button/label copy) - Test:
app/test/flows/earn_test.dart(flow: tap check-off → submitCompletion dispatched)
Interfaces: Consumes CatalogChoreCompletionSubmitted, state.assignedToMember, eligibilityFor. Produces: a "Check off / Done" affordance on an assigned expectation card dispatching CatalogChoreCompletionSubmitted(choreId: chore.id).
-
Step 1: Failing flow test in
earn_test.dart(mirrorearn_claim_test.dart's harness): seed the selected member with an assigned expectation, warp to Earn, select the To-Do filter, tap the check-off button on the card, assertchores.submitCompletion(memberId:, choreId:)was called and the card shows a submitted state. -
Step 2: Run — expect FAIL (no check-off affordance) Run:
cd app && fvm flutter test test/flows/earn_test.dart -
Step 3: Implement — in
EarnChoreCard, for a chore that is an ASSIGNED expectation for the selected member (chore.kind == ChoreKind.expectation&& instate.assignedToMember(selectedMemberId)) and eligible, render a "Check off"/"Done" DS button (match the claim button's placement/idiom) dispatchingCatalogChoreCompletionSubmitted(choreId: chore.id); show a submitting spinner + submitted/done label from Task 3's submit state (orisChoreDoneInCurrentPeriodif the card already reads it). Do NOT add submit to bounties (Join) or items assigned to others (grayed). -
Step 4: Run — expect PASS, then FULL suite (≥ 768).
fvm dart format+cd app && fvm flutter analyze lib/inside/routes/authenticated/earn. -
Step 5: Commit
git add app/lib/inside/routes/authenticated/earn/earn_chore_card.dart app/lib/inside/i18n/strings.dart app/test/flows/earn_test.dart
git commit -m "feat: check-off (submit) affordance on Earn To-Do cards (Phase B)"
Task 5: Phase-B verification + graphify
Files: none (verification)
- Step 1: FULL app suite —
cd app && fvm flutter test→All tests passed!, count ≥ 768 (grows). Run fromapp/. - Step 2: Grep guard —
grep -rn "CatalogBloc(" app/lib app/test— all construction sites compile (Phase B added no ctor param; belt-and-suspenders check). - Step 3: Analyze —
cd app && fvm flutter analyze lib/inside/blocs/catalog lib/inside/routes/authenticated/earn lib/inside/routes/authenticated/rewards_tab→ 0 issues.fvm dart formattouched files. - Step 4: graphify —
graphify update .at the repo root. Confirm no stray generated files staged.
Self-review (spec coverage)
- Earn = my assigned chores as a checkable To-Do (submit here) — Tasks 1 (assignedToMe) + 3 (event) + 4 (check-off UI). ✅
- member-switcher defaults to self — Task 2 (applies to both Earn and Rewards, which each run
_onStarted). ✅ - bounties I can join — already wired (
EarnFilter.claimable+CatalogBountyClaimed); Task 1 relabels copy to "Joinable". ✅ - Rewards personalization — free via Task 2; redeem already wired. ✅
- Suggest/requestChore relocation + Household removal — NOT here; Phase D. ✅
- Baseline holds / full-suite each task / no ctor-param break / graphify — Global Constraints + Task 5. ✅