Skip to main content

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 bare flutter/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 the client_sdk facade.
  • 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 app ChoresRepository layer.
  • @JsonSerializable bloc states: admin-CRUD bloc states standardize on @JsonSerializable (per the E1 decision, matched by RewardsState/ActivitiesState). New ChoresManagementState follows suit → requires a generated state.g.dart.
  • Codegen scoping (clobber-guard lesson): regenerate with a scoped --build-filter and NEVER a blanket --delete-conflicting-outputs across the app. After any regen, git status and 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 in router.dart (deep-link + generated-name stability). Only the hub rows that reach them are replaced.
  • Tabs use DsSegmented<T> (the DS atom) — never Flutter TabBar. 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 ChoresManagementBloc must be told to reload. Every context.router.push(ChoreEditorRoute(...)) from a Chores/Bounties body is awaited and followed by ChoresManagementStarted() (guarded by context.mounted).

File Structure

Create:

  • app/lib/inside/blocs/chores_management/events.dartChoresManagementEvent (Started/Archived/Restored).
  • app/lib/inside/blocs/chores_management/state.dartChoresManagementState (@JsonSerializable) + ChoresManagementStatus.
  • app/lib/inside/blocs/chores_management/state.g.dart — generated (codegen).
  • app/lib/inside/blocs/chores_management/bloc.dartChoresManagementBloc (mirrors RewardsBloc).
  • app/lib/inside/routes/authenticated/rewards/rewards_manage_body.dart — extracted RewardsManageBody widget.
  • app/lib/inside/routes/authenticated/activities/activities_manage_body.dart — extracted ActivitiesManageBody widget.
  • app/lib/inside/routes/authenticated/manage/chores_manage_body.dartChoresManageBody({required ChoreKind kind}) widget (+ _ChoreManageRow).
  • app/lib/inside/routes/authenticated/manage/page.dartManagePage (@RoutePage(name: 'ManageRoute')) + ManageTab enum.
  • 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.dartgetChores({includeArchived}) + archiveChore + restoreChore.
  • app/test/util/mocks/mocked_app.dart (or wherever MockChoresRepository fallbacks/registration live) — register the new methods if the harness needs it.
  • app/lib/inside/routes/authenticated/chore_editor/page.dartinitialKind field, thread to ChoreEditorStarted.
  • app/lib/inside/blocs/chore_editor/events.dartChoreEditorStarted gains initialKind.
  • app/lib/inside/blocs/chore_editor/bloc.dart_onStarted uses initialKind as create-mode default.
  • app/lib/inside/routes/authenticated/rewards/page.dartbuild renders RewardsManageBody (extraction).
  • app/lib/inside/routes/authenticated/activities/page.dartbuild renders ActivitiesManageBody (extraction).
  • app/lib/inside/routes/authenticated/admin/page.dart — replace rewardsEntry + activitiesEntry rows with one manageEntryManageRoute.
  • 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.client facade methods getChores({bool includeArchived}), archiveChore(String id), restoreChore(String id) (already declared in packages/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 in app/pubspec.yaml), the seedChore factory signature in client_sdk_testing, and whether Client is 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 MockChoresRepository needs a fallback/stub for archiveChore/restoreChore/the new getChores signature (mocktail registerFallbackValue or a when(...) default), add it in app/test/util/mocks/mocked_app.dart so 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); AppBloc base (app/lib/inside/blocs/base.dart); SDK ValidationException/DomainRuleException.
  • Produces: ChoresManagementBloc({required ChoresRepository choresRepository}); events ChoresManagementStarted, ChoresManagementArchived(String id), ChoresManagementRestored(String id); ChoresManagementState { ChoresManagementStatus status, List<Chore> chores, String? errorMessage } with copyWith; 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 and seedChore's kind parameter before running; adjust to match client_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 + log accessor by reading app/lib/inside/blocs/base.dart and app/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 a kind field, defaulting to ChoreKind.expectation in create mode).

  • Produces: ChoreEditorPage({super.key, this.choreId, this.initialKind}); ChoreEditorStarted({this.choreId, this.initialKind}); create-mode default kind = initialKind ?? ChoreKind.expectation. ChoreEditorRoute codegen gains an initialKind named 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: RewardsManageBody and ActivitiesManageBodyStatelessWidgets containing the Scaffold.body content (the BlocListener + BlocBuilder + add-button + list + _*Row), consuming their existing page-scope bloc from context (provided by wrappedRoute, 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.dartActivitiesManageBody 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. Filters state.chores to kind; 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 plain Strings class, add these to the correct source (.i18n.json/.slang + regen) instead — read strings.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.emoji field names; DsButton's expand/icon params (they exist on the rewards page); Strings.retryLabel + Strings.rewardsDialogDismiss exist (reused from the rewards page). ChoreEditorRoute's codegen'd constructor accepts choreId + (post-Task-3) initialKind. Editing must NOT override an existing chore's kind — Task 3 ignores initialKind when choreId != null, and this passes null in 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 (from router.dart).

  • Produces: ManagePage (@RoutePage(name: 'ManageRoute'), AutoRouteWrapper); ManageTab { rewards, activities, chores, bounties }; codegen ManageRoute.

  • 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/seedChore signatures, the MocksContainer field names (rewardsRepository, activitiesRepository, choresRepository, currentMemberRepository), and the expectedEvents analytics-string format used in rewards_test.dart. The MorePage.adminEntry key is confirmed to exist; AdminHubPage.manageEntry is 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/ActivitiesStarted construction matches activities/page.dart's wrappedRoute exactly, and the three repository import paths resolve. DsSegment.label is String; Strings.* are static const String, so const 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 /rewards entry (the getCurrentMember reference). 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()). Removes AdminHubPage.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.adminEntryAdminHubPage.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 (kind param) + 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 rewrite router.gr.dart. Each is scoped + git status-checked. If a regen also rewrites a state.g.dart or another .gr.dart you 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.dart is 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 on feat/mvp1-personas-authz (or cherry-pick) before the final review, and defer worktree cleanup to branch-finish.