Catalog Restructure — Phase C: Unified Admin Manage Page — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the two separate Admin-hub rows ("Manage Rewards" + "Manage Activities") with ONE "Manage catalog" row that opens a single tabbed page — Rewards · Activities · Chores · Bounties — where Rewards/Activities reuse their existing management bodies and Chores/Bounties get a new management list (create/edit/archive) reusing the existing chore editor.
Architecture: The tabbed page is a MultiBlocProvider host (RewardsBloc + ActivitiesBloc + ChoresManagementBloc, all started eagerly) with a DsSegmented<ManageTab> selector switching between four body widgets. Rewards/Activities bodies are extracted from their existing pages (the standalone /rewards and /activities routes stay registered for deep-links; only the hub rows are replaced). Chores/Bounties share one body widget parameterized by ChoreKind, driven by a new ChoresManagementBloc that mirrors RewardsBloc (load-after-write, guarded errors) and pushes the full-screen ChoreEditorRoute for create/edit.
Tech Stack: Flutter 3.44 / Dart 3.9 (FVM), flutter_bloc, auto_route (codegen router.gr.dart), design_system atoms (DsSegmented, DsAppBackdrop, DsSection, DsButton, DsEmptyState, DsRow), flow_test harness (flowTest + MocksContainer), json_serializable (state.g.dart).
Global Constraints
- FVM only:
fvm flutter .../fvm dart ...— never bareflutter/dart. - One data path: Bloc → Repository → Client facade → Service → Adapter. Domain rules live in the SDK Service; repositories are thin delegates. Presentation NEVER imports
drift/supabase— only theclient_sdkfacade. - No SDK change required: the client facade already exposes
getChores({bool includeArchived}),archiveChore(String id),restoreChore(String id)(packages/client_sdk/lib/src/client/client.dart:439-534). Phase C only forwards them at the appChoresRepositorylayer. @JsonSerializablebloc states: admin-CRUD bloc states standardize on@JsonSerializable(per the E1 decision, matched byRewardsState/ActivitiesState). NewChoresManagementStatefollows suit → requires a generatedstate.g.dart.- Codegen scoping (clobber-guard lesson): regenerate with a scoped
--build-filterand NEVER a blanket--delete-conflicting-outputsacross the app. After any regen,git statusand restore hand-maintained collateral (*.gr.dart, other*.g.dart) from HEAD if the run touched them. Exact commands are given per task. - Brand-neutral: no brand strings in package/class/file names; all user-facing copy via
Strings(app/lib/inside/i18n/strings.dart). - Keep the two old routes registered:
/rewards(RewardsRoute) and/activities(ActivitiesRoute) stay inrouter.dart(deep-link + generated-name stability). Only the hub rows that reach them are replaced. - Tabs use
DsSegmented<T>(the DS atom) — never FlutterTabBar. It already handles 4+ segments (Earn uses 5). - Suite baselines must not drop. Current app baseline is 777 tests green on
feat/mvp1-personas-authz. Run the FULL app suite (fvm flutter test) at the end of any task that changes a constructor signature, a route, or generated code — scoped-file runs mask cross-site compile breaks (the Phase A T1 lesson). - Reload-after-editor: the chore editor is a full-screen pushed route with its OWN bloc; after it pops, the Manage page's
ChoresManagementBlocmust be told to reload. Everycontext.router.push(ChoreEditorRoute(...))from a Chores/Bounties body isawaited and followed byChoresManagementStarted()(guarded bycontext.mounted).
File Structure
Create:
app/lib/inside/blocs/chores_management/events.dart—ChoresManagementEvent(Started/Archived/Restored).app/lib/inside/blocs/chores_management/state.dart—ChoresManagementState(@JsonSerializable) +ChoresManagementStatus.app/lib/inside/blocs/chores_management/state.g.dart— generated (codegen).app/lib/inside/blocs/chores_management/bloc.dart—ChoresManagementBloc(mirrorsRewardsBloc).app/lib/inside/routes/authenticated/rewards/rewards_manage_body.dart— extractedRewardsManageBodywidget.app/lib/inside/routes/authenticated/activities/activities_manage_body.dart— extractedActivitiesManageBodywidget.app/lib/inside/routes/authenticated/manage/chores_manage_body.dart—ChoresManageBody({required ChoreKind kind})widget (+_ChoreManageRow).app/lib/inside/routes/authenticated/manage/page.dart—ManagePage(@RoutePage(name: 'ManageRoute')) +ManageTabenum.app/test/unit/repositories/chores_repository_test.dart— repo forwarder tests (if no existing file).app/test/unit/blocs/chores_management_bloc_test.dart— bloc tests.app/test/flows/manage_test.dart— Manage page flow test.
Modify:
app/lib/outside/repositories/chores/chores_repository.dart—getChores({includeArchived})+archiveChore+restoreChore.app/test/util/mocks/mocked_app.dart(or whereverMockChoresRepositoryfallbacks/registration live) — register the new methods if the harness needs it.app/lib/inside/routes/authenticated/chore_editor/page.dart—initialKindfield, thread toChoreEditorStarted.app/lib/inside/blocs/chore_editor/events.dart—ChoreEditorStartedgainsinitialKind.app/lib/inside/blocs/chore_editor/bloc.dart—_onStartedusesinitialKindas create-mode default.app/lib/inside/routes/authenticated/rewards/page.dart—buildrendersRewardsManageBody(extraction).app/lib/inside/routes/authenticated/activities/page.dart—buildrendersActivitiesManageBody(extraction).app/lib/inside/routes/authenticated/admin/page.dart— replacerewardsEntry+activitiesEntryrows with onemanageEntry→ManageRoute.app/lib/inside/routes/router.dart— register/manage(ManageRoute) with[_mustChangeGuard, AdminGuard(...)]; import the page.app/lib/inside/routes/router.gr.dart— regenerated (codegen; DO NOT hand-edit).app/lib/inside/i18n/strings.dart— new keys (per task).
Task 1: ChoresRepository — archived-inclusive read + archive/restore forwarders
Files:
- Modify:
app/lib/outside/repositories/chores/chores_repository.dart:23-26(getChores) + add archive/restore - Modify (if needed):
app/test/util/mocks/mocked_app.dart(MockChoresRepository fallback registration) - Test:
app/test/unit/repositories/chores_repository_test.dart
Interfaces:
-
Consumes:
SdkClientProvider.clientfacade methodsgetChores({bool includeArchived}),archiveChore(String id),restoreChore(String id)(already declared inpackages/client_sdk/lib/src/client/client.dart:444,531,534). -
Produces:
ChoresRepository.getChores({bool includeArchived = false}),Future<Chore> archiveChore(String id),Future<Chore> restoreChore(String id). -
Step 1: Write the failing test
Create app/test/unit/repositories/chores_repository_test.dart (check first for an existing chores repo test to extend; if one exists, add these groups to it instead of creating a new file):
import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk_testing/client_sdk_testing.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:rewhaven_app/outside/client_providers/sdk_client_provider.dart';
import 'package:rewhaven_app/outside/repositories/chores/chores_repository.dart';
class _MockClient extends Mock implements Client {}
class _MockClientProvider extends Mock implements SdkClientProvider {}
void main() {
late _MockClient client;
late _MockClientProvider provider;
late ChoresRepository repo;
setUp(() {
client = _MockClient();
provider = _MockClientProvider();
when(() => provider.client).thenReturn(client);
repo = ChoresRepository(clientProvider: provider);
});
group('getChores', () {
test('forwards includeArchived: false by default', () async {
when(() => client.getChores(includeArchived: false))
.thenAnswer((_) async => const <Chore>[]);
await repo.getChores();
verify(() => client.getChores(includeArchived: false)).called(1);
});
test('forwards includeArchived: true when requested', () async {
when(() => client.getChores(includeArchived: true))
.thenAnswer((_) async => const <Chore>[]);
await repo.getChores(includeArchived: true);
verify(() => client.getChores(includeArchived: true)).called(1);
});
});
group('archive/restore', () {
final chore = seedChore(id: 'c1', householdId: 'h1', name: 'Dishes');
test('archiveChore forwards the id', () async {
when(() => client.archiveChore('c1')).thenAnswer((_) async => chore);
await repo.archiveChore('c1');
verify(() => client.archiveChore('c1')).called(1);
});
test('restoreChore forwards the id', () async {
when(() => client.restoreChore('c1')).thenAnswer((_) async => chore);
await repo.restoreChore('c1');
verify(() => client.restoreChore('c1')).called(1);
});
});
}
NOTE: verify the exact import prefix (
package:rewhaven_app/...vs the app's real package name inapp/pubspec.yaml), theseedChorefactory signature inclient_sdk_testing, and whetherClientis the correct facade type name before running. Adjust to match the codebase; do not invent a factory that doesn't exist.
- Step 2: Run test to verify it fails
Run: cd app && fvm flutter test test/unit/repositories/chores_repository_test.dart
Expected: FAIL — getChores has no includeArchived param; archiveChore/restoreChore don't exist on ChoresRepository.
- Step 3: Write minimal implementation
In app/lib/outside/repositories/chores/chores_repository.dart, change getChores (line 25-26) and add archive/restore after updateChore (after line 114):
/// One-shot read of chores. Active only by default; pass [includeArchived]
/// for the full set (management surfaces list archived chores so they can be
/// restored). The imperative twin of [watchChores] (which stays active-only).
Future<List<Chore>> getChores({bool includeArchived = false}) =>
_clientProvider.client.getChores(includeArchived: includeArchived);
/// Archive a chore (A.2): hide it from the default list WITHOUT deleting it;
/// its append-only completion history is preserved. Thin delegate to the SDK
/// ChoreService (RLS + admin authz enforced there).
Future<Chore> archiveChore(String id) =>
_clientProvider.client.archiveChore(id);
/// Restore a previously archived chore back into the default list.
Future<Chore> restoreChore(String id) =>
_clientProvider.client.restoreChore(id);
- Step 4: Run the repo test + the full app suite
Run: cd app && fvm flutter test test/unit/repositories/chores_repository_test.dart
Expected: PASS.
Run: cd app && fvm flutter test
Expected: baseline (777) + the new tests, all green. getChores() callers (TodayChoresBloc, CatalogBloc) still compile — the new param defaults to false, so no call site changes.
If the flow-test harness
MockChoresRepositoryneeds a fallback/stub forarchiveChore/restoreChore/the newgetChoressignature (mocktailregisterFallbackValueor awhen(...)default), add it inapp/test/util/mocks/mocked_app.dartso unrelated flow tests that touch chores don't throw on an unstubbed call. Only add what the suite actually requires.
- Step 5: Commit
git add app/lib/outside/repositories/chores/chores_repository.dart app/test/unit/repositories/chores_repository_test.dart
git add -A app/test/util/mocks/mocked_app.dart 2>/dev/null || true
git commit -m "feat: ChoresRepository forwards includeArchived + archive/restore"
Task 2: ChoresManagementBloc
Files:
- Create:
app/lib/inside/blocs/chores_management/events.dart - Create:
app/lib/inside/blocs/chores_management/state.dart - Create:
app/lib/inside/blocs/chores_management/state.g.dart(codegen) - Create:
app/lib/inside/blocs/chores_management/bloc.dart - Test:
app/test/unit/blocs/chores_management_bloc_test.dart
Interfaces:
- Consumes:
ChoresRepository.getChores({includeArchived}),.archiveChore(id),.restoreChore(id)(Task 1);AppBlocbase (app/lib/inside/blocs/base.dart); SDKValidationException/DomainRuleException. - Produces:
ChoresManagementBloc({required ChoresRepository choresRepository}); eventsChoresManagementStarted,ChoresManagementArchived(String id),ChoresManagementRestored(String id);ChoresManagementState { ChoresManagementStatus status, List<Chore> chores, String? errorMessage }withcopyWith;ChoresManagementStatus { initial, loading, ready, loadFailure, working, actionFailure }.
This bloc is a near-exact mirror of RewardsBloc/RewardsState/RewardsEvent (app/lib/inside/blocs/rewards/), substituting Chore for Reward and dropping the create/update events (chore create/edit go through the full-screen ChoreEditorRoute, not this bloc). Read those three files as the template.
- Step 1: Write the failing test
Create app/test/unit/blocs/chores_management_bloc_test.dart:
import 'package:bloc_test/bloc_test.dart';
import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk_testing/client_sdk_testing.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:rewhaven_app/inside/blocs/chores_management/bloc.dart';
import 'package:rewhaven_app/outside/repositories/chores/chores_repository.dart';
class _MockChoresRepository extends Mock implements ChoresRepository {}
void main() {
late _MockChoresRepository repo;
final expectationChore =
seedChore(id: 'c1', householdId: 'h1', name: 'Dishes', kind: ChoreKind.expectation);
final bountyChore =
seedChore(id: 'b1', householdId: 'h1', name: 'Garage', kind: ChoreKind.bounty);
setUp(() => repo = _MockChoresRepository());
blocTest<ChoresManagementBloc, ChoresManagementState>(
'Started loads chores (incl. archived) → ready',
setUp: () => when(() => repo.getChores(includeArchived: true))
.thenAnswer((_) async => [expectationChore, bountyChore]),
build: () => ChoresManagementBloc(choresRepository: repo),
act: (b) => b.add(ChoresManagementStarted()),
expect: () => [
isA<ChoresManagementState>()
.having((s) => s.status, 'status', ChoresManagementStatus.loading),
isA<ChoresManagementState>()
.having((s) => s.status, 'status', ChoresManagementStatus.ready)
.having((s) => s.chores.length, 'chores', 2),
],
);
blocTest<ChoresManagementBloc, ChoresManagementState>(
'Archived calls archiveChore then reloads',
setUp: () {
when(() => repo.archiveChore('c1')).thenAnswer((_) async => expectationChore);
when(() => repo.getChores(includeArchived: true))
.thenAnswer((_) async => [expectationChore]);
},
build: () => ChoresManagementBloc(choresRepository: repo),
act: (b) => b.add(ChoresManagementArchived('c1')),
verify: (_) => verify(() => repo.archiveChore('c1')).called(1),
);
blocTest<ChoresManagementBloc, ChoresManagementState>(
'ValidationException surfaces as actionFailure with safe message',
setUp: () => when(() => repo.archiveChore('c1'))
.thenThrow(const ValidationException('bad')),
build: () => ChoresManagementBloc(choresRepository: repo),
act: (b) => b.add(ChoresManagementArchived('c1')),
expect: () => [
isA<ChoresManagementState>()
.having((s) => s.status, 'status', ChoresManagementStatus.working),
isA<ChoresManagementState>()
.having((s) => s.status, 'status', ChoresManagementStatus.actionFailure)
.having((s) => s.errorMessage, 'errorMessage', 'bad'),
],
);
}
Verify
ValidationException's real constructor shape andseedChore'skindparameter before running; adjust to matchclient_sdk/client_sdk_testing.
- Step 2: Run test to verify it fails
Run: cd app && fvm flutter test test/unit/blocs/chores_management_bloc_test.dart
Expected: FAIL — the bloc/events/state don't exist.
- Step 3: Write events
app/lib/inside/blocs/chores_management/events.dart:
/// Events for the chores/bounties management surface (Admin → Manage → Chores
/// or Bounties tab). Mirrors [RewardsEvent] minus create/update: chore create
/// and edit go through the full-screen `ChoreEditorRoute`, not this bloc. This
/// bloc owns only the LIST (load, archive, restore) — the list is re-loaded
/// after the editor pops.
sealed class ChoresManagementEvent {}
/// Page init / reload: load ALL chores (active + archived, so archived items
/// can be restored from the list). Re-dispatched after the chore editor pops.
class ChoresManagementStarted extends ChoresManagementEvent {}
/// Archive the chore [id] (A.2 — non-destructive hide; completion history kept).
class ChoresManagementArchived extends ChoresManagementEvent {
ChoresManagementArchived(this.id);
final String id;
}
/// Restore a previously archived chore [id] back into the default list.
class ChoresManagementRestored extends ChoresManagementEvent {
ChoresManagementRestored(this.id);
final String id;
}
- Step 4: Write state
app/lib/inside/blocs/chores_management/state.dart (mirror of RewardsState, Chore for Reward):
import 'package:client_sdk/client_sdk.dart';
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'state.g.dart';
enum ChoresManagementStatus {
initial,
loading,
ready,
loadFailure,
working,
actionFailure,
}
/// Flat, `@JsonSerializable` state for the chores/bounties management surface
/// (matches the admin-CRUD bloc-state decision — see [RewardsState]). Holds the
/// FULL chore list (active + archived); the body widget filters by [ChoreKind]
/// per tab. [Chore] is itself `@JsonSerializable`, so the list round-trips for
/// the bloc devtools observer.
@JsonSerializable(explicitToJson: true)
class ChoresManagementState extends Equatable {
const ChoresManagementState({
this.status = ChoresManagementStatus.initial,
this.chores = const [],
this.errorMessage,
});
factory ChoresManagementState.fromJson(Map<String, dynamic> json) =>
_$ChoresManagementStateFromJson(json);
final ChoresManagementStatus status;
/// All chores (active + archived), re-read after every successful write.
final List<Chore> chores;
/// The typed exception's safe message, surfaced by the body's `BlocListener`
/// in a themed dialog when [status] is [ChoresManagementStatus.actionFailure].
final String? errorMessage;
Map<String, dynamic> toJson() => _$ChoresManagementStateToJson(this);
ChoresManagementState copyWith({
ChoresManagementStatus? status,
List<Chore>? chores,
String? Function()? setErrorMessage,
}) {
return ChoresManagementState(
status: status ?? this.status,
chores: chores ?? this.chores,
errorMessage: setErrorMessage != null ? setErrorMessage() : errorMessage,
);
}
@override
List<Object?> get props => [status, chores, errorMessage];
}
- Step 5: Generate state.g.dart (scoped)
Run from app/ (scoped build-filter — do NOT use blanket --delete-conflicting-outputs):
cd app && fvm dart run build_runner build --build-filter "lib/inside/blocs/chores_management/state.g.dart"
Then git status — if the run touched any OTHER generated file (*.gr.dart, other *.g.dart), restore it from HEAD: git checkout -- <that file>.
- Step 6: Write bloc
app/lib/inside/blocs/chores_management/bloc.dart (mirror of RewardsBloc — reload-after-write + _runGuarded; NO created/updated handlers):
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:client_sdk/client_sdk.dart';
import '../../../outside/repositories/chores/chores_repository.dart';
import '../base.dart';
import 'events.dart';
import 'state.dart';
export 'events.dart';
export 'state.dart';
/// Page-scope bloc for the Chores + Bounties management tabs of the Admin
/// Manage page. Owns the LIST only (load / archive / restore); create + edit go
/// through the full-screen `ChoreEditorRoute`, and the body re-dispatches
/// [ChoresManagementStarted] when that route pops. Mirrors [RewardsBloc]:
/// reload-after-write (a one-shot archived-inclusive read, not the active-only
/// stream), guarded typed errors surfaced as
/// [ChoresManagementStatus.actionFailure].
class ChoresManagementBloc
extends AppBloc<ChoresManagementEvent, ChoresManagementState> {
ChoresManagementBloc({required ChoresRepository choresRepository})
: _choresRepository = choresRepository,
super(const ChoresManagementState()) {
on<ChoresManagementStarted>(_onStarted, transformer: sequential());
on<ChoresManagementArchived>(_onArchived, transformer: sequential());
on<ChoresManagementRestored>(_onRestored, transformer: sequential());
}
final ChoresRepository _choresRepository;
Future<void> _onStarted(
ChoresManagementStarted event,
Emitter<ChoresManagementState> emit,
) async {
emit(state.copyWith(status: ChoresManagementStatus.loading));
try {
final chores = await _choresRepository.getChores(includeArchived: true);
emit(
state.copyWith(
status: ChoresManagementStatus.ready,
chores: chores,
setErrorMessage: () => null,
),
);
} on Exception catch (e) {
log.warning('_onStarted: failed to load chores.', e);
emit(
state.copyWith(
status: ChoresManagementStatus.loadFailure,
setErrorMessage: () => e.toString(),
),
);
}
}
Future<void> _onArchived(
ChoresManagementArchived event,
Emitter<ChoresManagementState> emit,
) => _runGuarded(emit, () => _choresRepository.archiveChore(event.id));
Future<void> _onRestored(
ChoresManagementRestored event,
Emitter<ChoresManagementState> emit,
) => _runGuarded(emit, () => _choresRepository.restoreChore(event.id));
/// Runs the write [action], then RELOADS the full chore list. Typed SDK
/// failures become a [ChoresManagementStatus.actionFailure] carrying the safe
/// message — surfaced, never swallowed. On failure the prior list is kept.
Future<void> _runGuarded(
Emitter<ChoresManagementState> emit,
Future<void> Function() action,
) async {
emit(
state.copyWith(
status: ChoresManagementStatus.working,
setErrorMessage: () => null,
),
);
try {
await action();
final chores = await _choresRepository.getChores(includeArchived: true);
emit(
state.copyWith(
status: ChoresManagementStatus.ready,
chores: chores,
setErrorMessage: () => null,
),
);
} on ValidationException catch (e) {
emit(
state.copyWith(
status: ChoresManagementStatus.actionFailure,
setErrorMessage: () => e.message,
),
);
} on DomainRuleException catch (e) {
emit(
state.copyWith(
status: ChoresManagementStatus.actionFailure,
setErrorMessage: () => e.message,
),
);
} on Exception catch (e) {
emit(
state.copyWith(
status: ChoresManagementStatus.actionFailure,
setErrorMessage: () => e.toString(),
),
);
}
}
}
Confirm
AppBloc's base signature +logaccessor by readingapp/lib/inside/blocs/base.dartandapp/lib/inside/blocs/rewards/bloc.dart(which uses both). Match exactly.
- Step 7: Run the bloc test + full suite
Run: cd app && fvm flutter test test/unit/blocs/chores_management_bloc_test.dart
Expected: PASS.
Run: cd app && fvm flutter test
Expected: green (new bloc has no call sites yet, so no regressions).
- Step 8: Commit
git add app/lib/inside/blocs/chores_management/ app/test/unit/blocs/chores_management_bloc_test.dart
git commit -m "feat: ChoresManagementBloc (load/archive/restore, mirrors RewardsBloc)"
Task 3: ChoreEditorRoute — optional initialKind (preset bounty from the Bounties tab)
Files:
- Modify:
app/lib/inside/blocs/chore_editor/events.dart:59-62(ChoreEditorStarted) - Modify:
app/lib/inside/blocs/chore_editor/bloc.dart(_onStarted create-mode default) - Modify:
app/lib/inside/routes/authenticated/chore_editor/page.dart:22,55-65(field + thread) - Modify:
app/lib/inside/routes/router.gr.dart(regenerated) - Test:
app/test/unit/blocs/chore_editor_bloc_test.dart(extend existing if present)
Interfaces:
-
Consumes: existing
ChoreEditorBloc,ChoreEditorState(has akindfield, defaulting toChoreKind.expectationin create mode). -
Produces:
ChoreEditorPage({super.key, this.choreId, this.initialKind});ChoreEditorStarted({this.choreId, this.initialKind}); create-mode default kind =initialKind ?? ChoreKind.expectation.ChoreEditorRoutecodegen gains aninitialKindnamed arg. -
Step 1: Write the failing test
Read the existing ChoreEditorBloc test (find it under app/test/) to match its style. Add:
blocTest<ChoreEditorBloc, ChoreEditorState>(
'Started in create mode with initialKind=bounty defaults kind to bounty',
setUp: () {
// stub members/places loads the bloc performs in _onStarted (match the
// existing create-mode test's stubs)
},
build: () => ChoreEditorBloc(
choresRepository: choresRepo,
householdRepository: householdRepo,
placesRepository: placesRepo,
),
act: (b) => b.add(ChoreEditorStarted(initialKind: ChoreKind.bounty)),
verify: (b) => expect(b.state.kind, ChoreKind.bounty),
);
Also assert the existing default is unchanged: ChoreEditorStarted() (no initialKind) still yields kind == ChoreKind.expectation in create mode (there is likely an existing test for this — keep it green).
- Step 2: Run test to verify it fails
Run: cd app && fvm flutter test test/unit/blocs/chore_editor_bloc_test.dart (adjust path to the real file)
Expected: FAIL — ChoreEditorStarted has no initialKind param.
- Step 3: Add initialKind to the event
app/lib/inside/blocs/chore_editor/events.dart:59-62:
/// Initialize editor: null choreId = new chore, non-null = edit existing.
/// [initialKind] presets the kind in CREATE mode only (e.g. the Bounties
/// management tab opens the editor with kind=bounty); ignored when editing an
/// existing chore (its own kind wins).
class ChoreEditorStarted extends ChoreEditorEvent {
ChoreEditorStarted({this.choreId, this.initialKind});
final String? choreId;
final ChoreKind? initialKind;
}
- Step 4: Use initialKind in the bloc's create-mode default
In app/lib/inside/blocs/chore_editor/bloc.dart, in _onStarted, where create mode seeds the initial kind (currently ChoreKind.expectation), change it to event.initialKind ?? ChoreKind.expectation. Read the handler first; apply ONLY to the create branch (choreId == null) — the edit branch must keep loading the existing chore's kind. Preserve the expectation-pays-zero handling already in _onKindChanged/save.
- Step 5: Thread initialKind through the page
app/lib/inside/routes/authenticated/chore_editor/page.dart — line 22:
const ChoreEditorPage({super.key, this.choreId, this.initialKind});
/// The chore to edit. Null = create a new chore.
final String? choreId;
/// CREATE mode only: preset the kind (e.g. bounty from the Bounties tab).
final ChoreKind? initialKind;
And line 62 (wrappedRoute):
)..add(ChoreEditorStarted(choreId: choreId, initialKind: initialKind)),
Add the client_sdk import if ChoreKind isn't already imported in the page (it comes via package:client_sdk/client_sdk.dart).
- Step 6: Regenerate the router (scoped)
ChoreEditorRoute is codegen'd from the @RoutePage() annotation; the new constructor param must flow into router.gr.dart:
cd app && fvm dart run build_runner build --build-filter "lib/inside/routes/router.gr.dart"
git status — restore any collateral generated file the run touched (git checkout -- <file>).
- Step 7: Run editor tests + full suite
Run: cd app && fvm flutter test test/unit/blocs/chore_editor_bloc_test.dart
Expected: PASS (new + existing default both green).
Run: cd app && fvm flutter test
Expected: baseline green — existing ChoreEditorRoute() / ChoreEditorRoute(choreId: ...) call sites still compile (the new param is optional).
- Step 8: Commit
git add app/lib/inside/blocs/chore_editor/ app/lib/inside/routes/authenticated/chore_editor/page.dart app/lib/inside/routes/router.gr.dart app/test/unit/blocs/chore_editor_bloc_test.dart
git commit -m "feat: ChoreEditorRoute optional initialKind for bounty create"
Task 4: Extract RewardsManageBody + ActivitiesManageBody
Files:
- Create:
app/lib/inside/routes/authenticated/rewards/rewards_manage_body.dart - Create:
app/lib/inside/routes/authenticated/activities/activities_manage_body.dart - Modify:
app/lib/inside/routes/authenticated/rewards/page.dart(render the extracted body) - Modify:
app/lib/inside/routes/authenticated/activities/page.dart(render the extracted body)
Interfaces:
- Produces:
RewardsManageBodyandActivitiesManageBody—StatelessWidgets containing theScaffold.bodycontent (theBlocListener+BlocBuilder+ add-button + list +_*Row), consuming their existing page-scope bloc from context (provided bywrappedRoute, unchanged). They do NOT create their own bloc. - Consumed by: Task 6's
ManagePage(renders them under the Rewards/Activities tabs) and the existing standalone pages.
This is a pure refactor: no behavior change, no key changes, no new strings. Existing rewards_test.dart / activities flow tests MUST stay green unchanged.
- Step 1: Extract RewardsManageBody
Create app/lib/inside/routes/authenticated/rewards/rewards_manage_body.dart. Move the widget currently returned by RewardsManagementPage.build's Scaffold.body — i.e. the BlocListener<RewardsBloc, RewardsState> subtree (page.dart:63-124) AND the _showActionFailureDialog helper AND the _RewardRow class — into a new RewardsManageBody extends StatelessWidget. Keep every Key(...) identical (RewardsPage.addButton, RewardsPage.rewardTile_*, RewardsPage.archiveButton_*, RewardsPage.restoreButton_*, RewardsPage.actionFailureMessage). The body reads RewardsBloc from context (already provided by the page's wrappedRoute).
class RewardsManageBody extends StatelessWidget {
const RewardsManageBody({super.key});
@override
Widget build(BuildContext context) {
// ...exact BlocListener<RewardsBloc,RewardsState>( ... ) subtree from page.dart:63-124...
}
// _showActionFailureDialog + _RewardRow moved here verbatim
}
- Step 2: Point the page at the body
In app/lib/inside/routes/authenticated/rewards/page.dart, replace the Scaffold's body: (lines 63-124) with body: const RewardsManageBody(), and import the new file. Delete the now-moved _showActionFailureDialog and _RewardRow from page.dart. wrappedRoute, the AppBar, and the @RoutePage(name: 'RewardsRoute') stay untouched.
- Step 3: Repeat for Activities
Same extraction for app/lib/inside/routes/authenticated/activities/page.dart → ActivitiesManageBody in activities_manage_body.dart. Preserve all ActivitiesPage.* keys, the showActivityEditorSheet/showSpendGatesSheet calls, and the _ActivityRow (move it into the body file). Page body: becomes const ActivitiesManageBody().
- Step 4: Run the rewards + activities flow tests + full suite
Run: cd app && fvm flutter test test/flows/rewards_test.dart (+ the activities flow test file)
Expected: PASS unchanged (same widget tree, same keys).
Run: cd app && fvm flutter test
Expected: baseline green.
- Step 5: Commit
git add app/lib/inside/routes/authenticated/rewards/ app/lib/inside/routes/authenticated/activities/
git commit -m "refactor: extract Rewards/Activities manage bodies for tabbed re-host"
Task 5: ChoresManageBody (parameterized by ChoreKind)
Files:
- Create:
app/lib/inside/routes/authenticated/manage/chores_manage_body.dart - Modify:
app/lib/inside/i18n/strings.dart(chores-management copy) - Test: covered by the Task 6 flow test (this widget has no standalone route yet); a focused analyze pass here.
Interfaces:
- Consumes:
ChoresManagementBloc(Task 2) from context;ChoreEditorRoute({choreId, initialKind})(Task 3);ChoreKind(expectation/bounty). - Produces:
ChoresManageBody({required ChoreKind kind})+_ChoreManageRow. Filtersstate.chorestokind; add button presets that kind; edit/archive/restore per row; reloads after the editor pops.
Mirror RewardsManageBody's structure (loading/loadFailure/empty/list + action-failure dialog), swapping in the chore list and the push-based create/edit.
- Step 1: Add strings
In app/lib/inside/i18n/strings.dart, add (match the file's existing declaration style):
// Manage → Chores / Bounties tabs (Phase C)
static const String choresManageAddChore = 'Add chore';
static const String choresManageAddBounty = 'Add bounty';
static const String choresManageChoresEmptyTitle = 'No chores yet';
static const String choresManageChoresEmptyBody =
'Add a chore for your household to see it here.';
static const String choresManageBountiesEmptyTitle = 'No bounties yet';
static const String choresManageBountiesEmptyBody =
'Add a bounty anyone can claim to see it here.';
static const String choresManageLoadErrorTitle = "Couldn't load chores";
static const String choresManageArchiveTooltip = 'Archive';
static const String choresManageRestoreTooltip = 'Restore';
static const String choresManageActionFailureTitle = 'Something went wrong';
static const String choresManageArchivedLabel = 'Archived';
If the codebase uses slang-managed (
slang) i18n rather than a plainStringsclass, add these to the correct source (.i18n.json/.slang+ regen) instead — readstrings.dart's header to see which. Do not hardcode literals in the widget.
- Step 2: Write the body widget
app/lib/inside/routes/authenticated/manage/chores_manage_body.dart:
import 'package:auto_route/auto_route.dart';
import 'package:client_sdk/client_sdk.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/chores_management/bloc.dart';
import '../../../i18n/strings.dart';
import '../../router.dart';
/// The list body for one of the two chore-management tabs, parameterized by
/// [kind]: [ChoreKind.expectation] = Chores tab, [ChoreKind.bounty] = Bounties
/// tab. Filters the shared [ChoresManagementBloc] list to [kind]. The add button
/// opens the full-screen `ChoreEditorRoute` preset to [kind]; tapping a row
/// opens it in edit mode. Because the editor is a pushed route with its own
/// bloc, each push is awaited and followed by [ChoresManagementStarted] to
/// reload. Mirrors `RewardsManageBody` for loading/empty/error handling.
class ChoresManageBody extends StatelessWidget {
const ChoresManageBody({required this.kind, super.key});
final ChoreKind kind;
bool get _isBounty => kind == ChoreKind.bounty;
Future<void> _openEditor(BuildContext context, {String? choreId}) async {
final bloc = context.read<ChoresManagementBloc>();
await context.router.push(
ChoreEditorRoute(choreId: choreId, initialKind: choreId == null ? kind : null),
);
bloc.add(ChoresManagementStarted());
}
@override
Widget build(BuildContext context) {
final theme = DsTheme.of(context);
return BlocListener<ChoresManagementBloc, ChoresManagementState>(
listenWhen: (p, c) =>
c.status == ChoresManagementStatus.actionFailure &&
p.status != ChoresManagementStatus.actionFailure,
listener: (context, state) =>
_showActionFailureDialog(context, state.errorMessage),
child: BlocBuilder<ChoresManagementBloc, ChoresManagementState>(
builder: (context, state) {
if (state.status == ChoresManagementStatus.loading ||
state.status == ChoresManagementStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == ChoresManagementStatus.loadFailure &&
state.chores.isEmpty) {
return DsEmptyState(
emoji: '⚠️',
title: Strings.choresManageLoadErrorTitle,
body: state.errorMessage,
action: TextButton(
onPressed: () => context
.read<ChoresManagementBloc>()
.add(ChoresManagementStarted()),
child: const Text(Strings.retryLabel),
),
);
}
final items =
state.chores.where((c) => c.kind == kind).toList(growable: false);
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(theme.spacing.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
DsButton(
key: Key(
_isBounty
? 'ChoresManage.addBountyButton'
: 'ChoresManage.addChoreButton',
),
label: _isBounty
? Strings.choresManageAddBounty
: Strings.choresManageAddChore,
icon: Icons.add,
expand: true,
onPressed: () => _openEditor(context),
),
SizedBox(height: theme.spacing.s5),
if (items.isEmpty)
DsEmptyState(
emoji: _isBounty ? '🏅' : '🧹',
title: _isBounty
? Strings.choresManageBountiesEmptyTitle
: Strings.choresManageChoresEmptyTitle,
body: _isBounty
? Strings.choresManageBountiesEmptyBody
: Strings.choresManageChoresEmptyBody,
)
else
Column(
children: <Widget>[
for (final chore in items)
_ChoreManageRow(
chore: chore,
onEdit: () => _openEditor(context, choreId: chore.id),
),
],
),
],
),
),
);
},
),
);
}
void _showActionFailureDialog(BuildContext context, String? message) {
final theme = DsTheme.of(context);
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
backgroundColor: theme.colors.surfaceRaised,
title: Text(
Strings.choresManageActionFailureTitle,
style: theme.typography.headingSm.copyWith(color: theme.colors.ink),
),
content: Text(
message ?? Strings.choresManageActionFailureTitle,
key: const Key('ChoresManage.actionFailureMessage'),
style: theme.typography.body.copyWith(color: theme.colors.ink),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text(Strings.rewardsDialogDismiss),
),
],
),
);
}
}
/// One chore row: emoji + name + (kind-appropriate subtitle), an "Archived"
/// treatment when inactive, a trailing archive/restore button, and tap-to-edit.
class _ChoreManageRow extends StatelessWidget {
const _ChoreManageRow({required this.chore, required this.onEdit});
final Chore chore;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
final theme = DsTheme.of(context);
final archived = !chore.isActive;
final titleColor = archived ? theme.colors.inkMuted : theme.colors.ink;
final emoji = chore.emoji;
return ListTile(
key: Key('ChoresManage.tile_${chore.id}'),
contentPadding: EdgeInsets.symmetric(
horizontal: theme.spacing.s2,
vertical: theme.spacing.s1,
),
leading: Text(
emoji == null || emoji.isEmpty ? '🧹' : emoji,
style: theme.typography.body.copyWith(fontSize: 24),
),
title: Text(
chore.name,
style: theme.typography.body.copyWith(color: titleColor),
),
subtitle: archived
? Text(
Strings.choresManageArchivedLabel,
style: theme.typography.bodyDense
.copyWith(color: theme.colors.inkMuted),
)
: null,
trailing: IconButton(
key: Key(
archived
? 'ChoresManage.restoreButton_${chore.id}'
: 'ChoresManage.archiveButton_${chore.id}',
),
icon: Icon(
archived ? Icons.unarchive_outlined : Icons.archive_outlined,
color: theme.colors.inkMuted,
),
tooltip: archived
? Strings.choresManageRestoreTooltip
: Strings.choresManageArchiveTooltip,
onPressed: () => context.read<ChoresManagementBloc>().add(
archived
? ChoresManagementRestored(chore.id)
: ChoresManagementArchived(chore.id),
),
),
onTap: onEdit,
);
}
}
Verify against the codebase before running:
Chore.isActive/Chore.emojifield names;DsButton'sexpand/iconparams (they exist on the rewards page);Strings.retryLabel+Strings.rewardsDialogDismissexist (reused from the rewards page).ChoreEditorRoute's codegen'd constructor acceptschoreId+ (post-Task-3)initialKind. Editing must NOT override an existing chore's kind — Task 3 ignoresinitialKindwhenchoreId != null, and this passesnullin that case as belt-and-suspenders.
- Step 3: Analyze (behavioral coverage lands in Task 6's flow test)
Run: cd app && fvm flutter analyze lib/inside/routes/authenticated/manage/chores_manage_body.dart
Expected: no errors. (The widget needs the tabbed host + bloc provider to render, so behavioral assertions live in Task 6.)
- Step 4: Commit
git add app/lib/inside/routes/authenticated/manage/chores_manage_body.dart app/lib/inside/i18n/strings.dart
git commit -m "feat: ChoresManageBody (kind-parameterized chores/bounties admin list)"
Task 6: ManagePage tabbed host + ManageRoute
Files:
- Create:
app/lib/inside/routes/authenticated/manage/page.dart - Modify:
app/lib/inside/routes/router.dart(register/manage) - Modify:
app/lib/inside/routes/router.gr.dart(regenerated) - Modify:
app/lib/inside/i18n/strings.dart(title + tab labels) - Test:
app/test/flows/manage_test.dart
Interfaces:
-
Consumes:
RewardsBloc+RewardsRepository,ActivitiesBloc+ActivitiesRepository,ChoresManagementBloc+ChoresRepository;RewardsManageBody,ActivitiesManageBody(Task 4),ChoresManageBody(Task 5);DsSegmented<ManageTab>;AdminGuard+_mustChangeGuard(fromrouter.dart). -
Produces:
ManagePage(@RoutePage(name: 'ManageRoute'),AutoRouteWrapper);ManageTab { rewards, activities, chores, bounties }; codegenManageRoute. -
Step 1: Add strings
app/lib/inside/i18n/strings.dart:
// Admin Manage page (Phase C)
static const String manageTitle = 'Manage catalog';
static const String manageTabRewards = 'Rewards';
static const String manageTabActivities = 'Activities';
static const String manageTabChores = 'Chores';
static const String manageTabBounties = 'Bounties';
- Step 2: Write the failing flow test
Create app/test/flows/manage_test.dart, modeled on app/test/flows/rewards_test.dart (read it for the exact flowTest / createFlowConfig / warpToHome / MocksContainer API). Cover: navigate More → Admin → Manage; the Rewards tab shows a seeded reward; switching to the Bounties tab shows a seeded bounty and hides the reward.
// imports mirror rewards_test.dart
void main() {
setUpAll(registerClientSdkFallbacks);
final admin = seedMember(/* id:'mom', kind: MemberKind.parent, roles:{MemberRole.admin} — match rewards_test.dart */);
final movieNight = seedReward(id: 'reward-movie', /* householdId */ name: 'Movie night', tokenCost: 30);
final garageBounty = seedChore(id: 'b1', /* householdId */ name: 'Clean garage', kind: ChoreKind.bounty);
final dishesChore = seedChore(id: 'c1', /* householdId */ name: 'Dishes', kind: ChoreKind.expectation);
flowTest<MocksContainer>(
'admin Manage page switches between catalog-type tabs',
config: createFlowConfig(),
descriptions: const [/* one per screenshot trip */],
test: (tester) async {
await tester.setUp(warp: warpToHome);
await tester.screenshot(
description: 'manage page — rewards tab then bounties tab',
arrangeBeforeActions: (arrange) {
when(arrange.mocks.currentMemberRepository.watch)
.thenAnswer((_) => Stream.value(admin));
when(() => arrange.mocks.rewardsRepository
.getRewards(includeArchived: any(named: 'includeArchived')))
.thenAnswer((_) async => [movieNight]);
when(() => arrange.mocks.activitiesRepository
.getActivities(includeArchived: any(named: 'includeArchived')))
.thenAnswer((_) async => const []);
when(() => arrange.mocks.choresRepository
.getChores(includeArchived: any(named: 'includeArchived')))
.thenAnswer((_) async => [dishesChore, garageBounty]);
},
actions: (actions) async {
await actions.userAction.tap(find.text(Strings.navMore));
await actions.testerAction.pumpAndSettle();
await actions.userAction.tap(find.byKey(const Key('MorePage.adminEntry')));
await actions.testerAction.pumpAndSettle();
await actions.userAction.tap(find.byKey(const Key('AdminHubPage.manageEntry')));
await actions.testerAction.pumpAndSettle();
// default tab = Rewards
// switch to Bounties
await actions.userAction.tap(find.text(Strings.manageTabBounties));
await actions.testerAction.pumpAndSettle();
},
expectations: (expectations) {
expectations.expect(find.text('Clean garage'), findsOneWidget,
reason: 'Bounties tab lists the bounty');
expectations.expect(find.text('Dishes'), findsNothing,
reason: 'Bounties tab excludes expectation chores');
},
expectedEvents: const ['[ANALYTIC] [page]: ManageRoute'],
);
},
);
}
Match the EXACT
seedMember/seedReward/seedChoresignatures, theMocksContainerfield names (rewardsRepository,activitiesRepository,choresRepository,currentMemberRepository), and theexpectedEventsanalytics-string format used inrewards_test.dart. TheMorePage.adminEntrykey is confirmed to exist;AdminHubPage.manageEntryis added in Task 7 — so this test's full nav path only resolves after Task 7 (see Step 7).
- Step 3: Run test to verify it fails
Run: cd app && fvm flutter test test/flows/manage_test.dart
Expected: FAIL — ManagePage/ManageRoute don't exist (and manageEntry not yet in the hub).
- Step 4: Write ManagePage
app/lib/inside/routes/authenticated/manage/page.dart:
import 'package:auto_route/auto_route.dart';
import 'package:client_sdk/client_sdk.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/activities/bloc.dart';
import '../../../blocs/chores_management/bloc.dart';
import '../../../blocs/rewards/bloc.dart';
import '../../../i18n/strings.dart';
import '../../../../outside/repositories/activities/activities_repository.dart';
import '../../../../outside/repositories/chores/chores_repository.dart';
import '../../../../outside/repositories/rewards/rewards_repository.dart';
import '../activities/activities_manage_body.dart';
import '../rewards/rewards_manage_body.dart';
import 'chores_manage_body.dart';
/// Which catalog type the Admin Manage page is showing.
enum ManageTab { rewards, activities, chores, bounties }
/// The unified Admin catalog-management surface (Phase C). One page, four tabs
/// (Rewards · Activities · Chores · Bounties) via [DsSegmented]. Replaces the
/// former separate "Manage Rewards" / "Manage Activities" admin-hub rows.
/// Hosts all three page-scope blocs eagerly (Rewards/Activities/ChoresManagement)
/// under a [MultiBlocProvider]; the visible tab body reads its own bloc from
/// context. Guarded by AdminGuard + MustChangePasswordGuard (in router.dart).
@RoutePage(name: 'ManageRoute')
class ManagePage extends StatefulWidget implements AutoRouteWrapper {
const ManagePage({super.key});
@override
Widget wrappedRoute(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
RewardsBloc(rewardsRepository: context.read<RewardsRepository>())
..add(RewardsStarted()),
),
BlocProvider(
create: (context) => ActivitiesBloc(
activitiesRepository: context.read<ActivitiesRepository>(),
)..add(ActivitiesStarted()),
),
BlocProvider(
create: (context) => ChoresManagementBloc(
choresRepository: context.read<ChoresRepository>(),
)..add(ChoresManagementStarted()),
),
],
child: this,
);
}
@override
State<ManagePage> createState() => _ManagePageState();
}
class _ManagePageState extends State<ManagePage> {
ManageTab _tab = ManageTab.rewards;
@override
Widget build(BuildContext context) {
final theme = DsTheme.of(context);
return DsBackdropDepthLevel(
child: DsAppBackdrop(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: theme.colors.ink),
onPressed: () => context.router.maybePop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
),
title: Text(
Strings.manageTitle,
style: theme.typography.headingLg.copyWith(color: theme.colors.ink),
),
),
body: Column(
children: <Widget>[
Padding(
padding: EdgeInsets.all(theme.spacing.s4),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DsSegmented<ManageTab>(
key: const Key('ManagePage.tabs'),
value: _tab,
onChanged: (t) => setState(() => _tab = t),
segments: const <DsSegment<ManageTab>>[
DsSegment(value: ManageTab.rewards, label: Strings.manageTabRewards),
DsSegment(value: ManageTab.activities, label: Strings.manageTabActivities),
DsSegment(value: ManageTab.chores, label: Strings.manageTabChores),
DsSegment(value: ManageTab.bounties, label: Strings.manageTabBounties),
],
),
),
),
Expanded(child: _body()),
],
),
),
),
);
}
Widget _body() {
switch (_tab) {
case ManageTab.rewards:
return const RewardsManageBody();
case ManageTab.activities:
return const ActivitiesManageBody();
case ManageTab.chores:
return const ChoresManageBody(kind: ChoreKind.expectation);
case ManageTab.bounties:
return const ChoresManageBody(kind: ChoreKind.bounty);
}
}
}
Confirm
ActivitiesBloc/ActivitiesStartedconstruction matchesactivities/page.dart'swrappedRouteexactly, and the three repository import paths resolve.DsSegment.labelisString;Strings.*arestatic const String, soconst DsSegment(...)is valid.
- Step 5: Register the route
In app/lib/inside/routes/router.dart, import the page and add alongside the /rewards + /activities entries (same guard pair):
AutoRoute(
path: '/manage',
page: ManageRoute.page,
guards: [
_mustChangeGuard,
AdminGuard(getCurrentMember: getCurrentMember),
],
),
Match the exact
AdminGuard(...)construction used by the neighboring/rewardsentry (thegetCurrentMemberreference). Read router.dart:343-364 to copy it verbatim.
- Step 6: Regenerate the router (scoped)
cd app && fvm dart run build_runner build --build-filter "lib/inside/routes/router.gr.dart"
git status — restore any collateral generated file touched (git checkout -- <file>).
- Step 7: Analyze + full suite (flow test greens after Task 7)
manage_test.dart's nav path needs the AdminHubPage.manageEntry row, added in Task 7. For this task's checkpoint:
Run: cd app && fvm flutter analyze lib/inside/routes/authenticated/manage/page.dart lib/inside/routes/router.dart
Expected: no errors.
Run: cd app && fvm flutter test
Expected: baseline green; manage_test.dart still RED on the missing manageEntry — record this in the ledger as expected, cleared by Task 7. (Do not force it green here by deep-linking; Task 7 is the natural resolver.)
- Step 8: Commit
git add app/lib/inside/routes/authenticated/manage/page.dart app/lib/inside/routes/router.dart app/lib/inside/routes/router.gr.dart app/lib/inside/i18n/strings.dart app/test/flows/manage_test.dart
git commit -m "feat: ManagePage tabbed admin catalog surface + /manage route"
Task 7: Replace the two Admin-hub rows with one "Manage catalog" row
Files:
- Modify:
app/lib/inside/routes/authenticated/admin/page.dart:63-76(rows) +app/lib/inside/i18n/strings.dart - Test:
app/test/flows/manage_test.dart(finish the nav path) + hub assertions
Interfaces:
-
Consumes:
ManageRoute(Task 6). -
Produces: hub row
Key('AdminHubPage.manageEntry')→context.router.push(const ManageRoute()). RemovesAdminHubPage.rewardsEntry+AdminHubPage.activitiesEntry. -
Step 1: Add the row string
app/lib/inside/i18n/strings.dart:
static const String adminManageTitle = 'Manage catalog';
(adminRewardsTitle / adminActivitiesTitle may become unused — leave them; removing shared strings risks other references. A later cleanup can prune if grep confirms no other use.)
- Step 2: Replace the rows
In app/lib/inside/routes/authenticated/admin/page.dart, replace the two _AdminRows at lines 63-76 (rewardsEntry + activitiesEntry) with ONE:
// Unified catalog management — Rewards / Activities / Chores / Bounties
// tabs (Phase C). Replaces the former separate Rewards + Activities rows.
_AdminRow(
rowKey: const Key('AdminHubPage.manageEntry'),
icon: Icons.tune_outlined,
title: Strings.adminManageTitle,
onTap: () => context.router.push(const ManageRoute()),
),
Leave rolesOwnersEntry, housesEntry, householdEntry, and the account-section rows unchanged. ManageRoute is already imported via router.dart (the page imports ../../../routes/router.dart).
- Step 3: Finish the flow test assertions
In app/test/flows/manage_test.dart, the nav path (More → MorePage.adminEntry → AdminHubPage.manageEntry → Manage) now resolves. Add a hub assertion (same flow or a second flowTest) that the old rows are gone:
expectations.expect(find.byKey(const Key('AdminHubPage.manageEntry')), findsOneWidget);
expectations.expect(find.byKey(const Key('AdminHubPage.rewardsEntry')), findsNothing);
expectations.expect(find.byKey(const Key('AdminHubPage.activitiesEntry')), findsNothing);
- Step 4: Run the flow test + full suite
Run: cd app && fvm flutter test test/flows/manage_test.dart
Expected: PASS.
Run: cd app && fvm flutter test
Expected: baseline + Phase C tests all green. Check any existing admin-hub flow test asserting AdminHubPage.rewardsEntry/activitiesEntry (search: grep -rn "rewardsEntry\|activitiesEntry" app/test) — those must now expect the rows absent or be removed.
- Step 5: Update graphify + commit
git add app/lib/inside/routes/authenticated/admin/page.dart app/lib/inside/i18n/strings.dart app/test/flows/manage_test.dart
git commit -m "feat: single Manage catalog admin-hub row (replaces Rewards+Activities rows)"
cd .. && graphify update .
git add graphify-out/ && git commit -m "chore: graphify update after Phase C" || true
Self-Review
1. Spec coverage (against docs/superpowers/specs/2026-07-21-catalog-earn-today-restructure-design.md §4 + phasing "Phase C"):
- "tabbed page (4 tabs) hosting Rewards/Activities + new Chores/Bounties management" → Tasks 4 (extract), 5 (chores body), 6 (host). ✅
- "replace the two hub rows with one 'Manage catalog' row" → Task 7. ✅
- "Bounties = ChoreKind.bounty filter" → Task 5 (
kindparam) + Task 3 (bounty create preset). ✅ - "reusing the chore editor sheet/route" → Task 5 pushes
ChoreEditorRoute. ✅ - Recommendations inline + badge → explicitly Phase D, NOT here (spec §4/§5, phasing). Not in scope. ✅
2. Placeholder scan: every code step carries real code or a precise line-range extraction; every command is exact (fvm + scoped build-filter). The > NOTE:/> blocks flag facts the implementer must verify against the codebase (factory signatures, package name, field names) — verification instructions, not placeholders, because the plan cannot know a factory's exact arg list without reading client_sdk_testing. ✅
3. Type consistency: ChoresManagementStatus values (initial/loading/ready/loadFailure/working/actionFailure) match RewardsStatus. ChoresManagementState.chores (not rewards). ManageTab { rewards, activities, chores, bounties } used identically in page.dart segments + _body(). ChoreEditorStarted({choreId, initialKind}) matches its use in ChoreEditorPage.wrappedRoute and ChoresManageBody._openEditor. getChores({includeArchived}) identical across repo, bloc, and test. ✅
Cross-cutting notes for the executor
- Two router regens (Task 3
initialKind, Task 6/manage) both rewriterouter.gr.dart. Each is scoped +git status-checked. If a regen also rewrites astate.g.dartor another.gr.dartyou didn't change, restore it from HEAD. - Full-suite discipline: Tasks 1, 3, 6, 7 change signatures/routes/codegen → each ends with a full
fvm flutter test, not just the scoped file (the Phase A T1 masked-compile-break lesson). - i18n source: if
strings.dartis slang-generated rather than a plain class, route all new copy through the slang source + regen (a>note flags this in Task 5). - Worktree accrual: subagent commits may land on
.claude/worktrees/agent-*branches; verify each task's HEAD is onfeat/mvp1-personas-authz(or cherry-pick) before the final review, and defer worktree cleanup to branch-finish.