Skip to main content

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>( across app/ + 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. Explicit git add (never -A/.). Do NOT run build_runner unless a @JsonSerializable state/model field is added; if a new transient state field is added and CatalogState is @JsonSerializable, annotate @JsonKey(includeToJson:false, includeFromJson:false) (like sibling transient fields) so state.g.dart is 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, getters expectations/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).
  • CatalogBloc already holds _choresRepository, _currentMemberRepository, _selectedMemberRepository; _onStarted seeds selectedMemberId from _selectedMemberRepository.current (bloc.dart:117); consent pattern needsParentalConsent(...) + _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.dartbuild() factory + setUp mocktail 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 (new EarnFilter value + _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 (assignedToMember undefined) Run: cd app && fvm flutter test test/unit/catalog_bloc_test.dart

  • Step 3: Implement the getter in state.dart (place near expectations/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: add assignedToMe to EarnFilter (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.dart then cd app && fvm flutter test Expected: 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 (_onStarted initial 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 initial selectedId is 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.dart IF a transient submit/done state is needed (a Set<String> submittingChoreIds, @JsonKey-excluded if the state is @JsonSerializable)
  • Test: app/test/unit/catalog_bloc_test.dart

Interfaces:

  • Consumes: _choresRepository.submitCompletion(memberId:, choreId:); existing needsParentalConsent/_emitConsentPrompt.

  • Produces: CatalogChoreCompletionSubmitted({required String choreId}) (uses state.selectedMemberId as the acting member). NO new constructor param on CatalogBloc.

  • 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 CatalogChoreCompletionSubmitted to events.dart; register on<CatalogChoreCompletionSubmitted>(_onCompletionSubmitted); write _onCompletionSubmitted mirroring TodayChoresBloc._onCompletionSubmitted (today_chores/bloc.dart:361): read state.selectedMemberId (return if null), run the existing needsParentalConsent/_emitConsentPrompt pre-check for the acting member, then await _choresRepository.submitCompletion(memberId: memberId, choreId: event.choreId), mapping errors to setErrorMessage. If EarnChoreCard needs a submit-in-flight signal, add Set<String> submittingChoreIds to 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 (mirror earn_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, assert chores.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 && in state.assignedToMember(selectedMemberId)) and eligible, render a "Check off"/"Done" DS button (match the claim button's placement/idiom) dispatching CatalogChoreCompletionSubmitted(choreId: chore.id); show a submitting spinner + submitted/done label from Task 3's submit state (or isChoreDoneInCurrentPeriod if 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 suitecd app && fvm flutter testAll tests passed!, count ≥ 768 (grows). Run from app/.
  • Step 2: Grep guardgrep -rn "CatalogBloc(" app/lib app/test — all construction sites compile (Phase B added no ctor param; belt-and-suspenders check).
  • Step 3: Analyzecd app && fvm flutter analyze lib/inside/blocs/catalog lib/inside/routes/authenticated/earn lib/inside/routes/authenticated/rewards_tab → 0 issues. fvm dart format touched files.
  • Step 4: graphifygraphify 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. ✅