Profile + Companion-as-Proxy Shell Chrome — 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: Make the celestial companion the active member's identity — a top-left app-bar avatar + the Profile nav icon both showing the active member's companion (emoji fallback) — and fold account/settings/switcher into a Profile tab that replaces More.
Architecture: Presentation + design-system only (NO SDK/schema/RLS/economy change). The companion "creature" moves from the retired bottom ambient layer to a shell top-bar avatar keyed on the selected/active member (a read; getCompanion is RLS-parental-read, no _assertActorSelf); its cosmic-dim ambiance becomes a backdrop layer. One identity resolver (companion-when-isCreated+enabled, else emoji) feeds both the top-bar avatar and the Profile nav icon. More→Profile in both role nav sets; Profile hosts the settings, the capability-gated account switcher, active-member subsections (My Cosmos), and logout at the bottom.
Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm flutter ...), flutter_bloc, auto_route, design_system, flow_test.
Global Constraints
- One data path: Bloc/Cubit → Repository →
client_sdkfacade. Presentation (app/lib/inside/**) never importsdrift/supabase. - NO SDK/schema/RLS/economy change. Presentation + DS only. The only SDK-facing addition is an app-repository method calling the EXISTING
client.getCompanion(memberId)facade for the selected member. - AUTHZ INVARIANT (load-bearing): the selected/active member is a DISPLAY lens; the authenticated member (
CurrentMemberRepository.current) stays the sole real actor for gated calls + RLS. The tab set stays keyed on the AUTHENTICATED member's kind (never the lens). - PROXY READ-ONLY RULE: when active member ≠ authenticated self, the companion sheet + My Cosmos are read-only (buy/equip/rename/edit hidden or disabled —
_assertActorSelfalready enforces server-side). - Naming: NEW app classes use the gadfly underscore
{Name}_Suffixconvention (camel_case_typesalready relaxed); DS additions areDs-prefixed PascalCase; models plain PascalCase; filessnake_case. No brand strings in identifiers; copy viaStrings. - Typed errors only (
on <Specific>; never bare catch / catch Error). Immutable state (copyWith + Equatable props). Reduced motion honored (MediaQuery.disableAnimations). - Flow tests gadfly-canonical (per-epic, ONE flowTest with multiple stories); baselines must not drop. TDD; frequent commits.
File Structure
Design system (packages/design_system/)
lib/src/templates/ds_adaptive_scaffold.dart— extendDsNavDestinationwith an optional customavatarwidget (overrides theIconDatawhen present); render it in the phone bottom bar + rail.lib/src/graphics/ds_companion_creature.dart— add a breathing scale + glow pulse to the idle (reduced-motion-safe).
App (app/lib/inside/)
routes/authenticated/shared/identity_avatar.dart— NEWIdentity_Avatar(companion-when-configured, else member emoji), used by the top-bar + the Profile nav icon.blocs/active_identity/cubit.dart(+state.dart) — NEWActiveIdentity_Cubit: resolves the active member (roster + selected id) and loads that member'sCompanionViewfor the avatar; reacts to switches.routes/authenticated/shell/shell_app_bar.dart— NEWShell_AppBar(companion avatar top-left + "viewing {member}" + balance) mounted by the 4 tab pages; replaces per-pageTopLevelHeaderusage +_EarnBrowseLabel.routes/authenticated/shell/page.dart— retire the bottomCompanionLayer; add the backdrop ambient layer; keep the actor-keyed loading gate; nav More→Profile.routes/authenticated/shell/companion_ambient_layer.dart— NEW backdrop cosmic-dim layer (behind content, front of houses).routes/authenticated/profile/page.dart(+ widgets) — NEWProfilePage(login-account frame + switcher + active-member subsections + logout bottom); absorbsmore/widgets/more_settings_tiles.dart.outside/repositories/companion/companion_repository.dart— addloadForMember(String memberId).inside/routes/router.dart(+ regenrouter.gr.dart) —MoreRoute→ProfileRoute.- Retire:
shell/companion_layer.dart(bottom scene),shared/top_level_header.dart+shared/account_switcher_chip.dart,earn/rewards_tab_EarnBrowseLabel.
Tests — DS golden/widget (nav avatar, idle); app widget (identity resolver, Profile nav icon, Profile page); flow (app/test/flows/shell_identity_test.dart).
Task 1: DS — DsNavDestination custom avatar icon
Files:
- Modify:
packages/design_system/lib/src/templates/ds_adaptive_scaffold.dart - Test:
packages/design_system/test/templates/ds_nav_destination_avatar_test.dart
Interfaces — Produces: DsNavDestination({required IconData icon, required String label, Widget? avatar}). When avatar != null, the phone bottom bar AND the rail render avatar in place of the Icon(icon), full-colour (NOT tinted by selected/unselected state); the icon remains the required fallback + semantics.
- Step 1: Failing widget test
// ds_nav_destination_avatar_test.dart
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('nav destination renders a custom avatar widget when provided',
(tester) async {
const avatarKey = Key('nav.avatar');
await tester.pumpWidget(MaterialApp(
theme: DsTheme.light.toThemeData(),
home: DsAdaptiveScaffold(
destinations: const [
DsNavDestination(icon: Icons.home_outlined, label: 'Home'),
DsNavDestination(
icon: Icons.person_outline,
label: 'Profile',
avatar: SizedBox(key: avatarKey, width: 24, height: 24),
),
],
selectedIndex: 0,
onDestinationSelected: (_) {},
body: const SizedBox(),
),
));
await tester.pump();
expect(find.byKey(avatarKey), findsOneWidget);
});
}
-
Step 2: Run — expect FAIL (
avatarparam undefined). Run:cd packages/design_system && fvm flutter test test/templates/ds_nav_destination_avatar_test.dart -
Step 3: Implement — add
final Widget? avatar;toDsNavDestination(+ constructor param). Find where the scaffold builds each destination's icon (both the phone bottom bar and the rail): where it currently doesIcon(destination.icon), usedestination.avatar ?? Icon(destination.icon). For the phone bottom barNavigationDestination(icon: destination.avatar ?? Icon(...), label: ...); the avatar renders full-colour (do not apply the selected/unselected icon theme to it). Read the existing render sites inds_adaptive_scaffold.dartand mirror them. -
Step 4: Run — PASS + full DS suite (
cd packages/design_system && fvm flutter test) — no baseline drop. -
Step 5: Commit
feat(ds): DsNavDestination optional custom avatar icon.
Task 2: DS — companion idle breathing + glow pulse
Files:
- Modify:
packages/design_system/lib/src/graphics/ds_companion_creature.dart - Test:
packages/design_system/test/graphics/ds_companion_creature_idle_test.dart(+ regen affected goldens)
Interfaces — Consumes/Produces: no signature change. The existing _idle controller (2.6s, repeat(reverse:true)) additionally drives a scale (~1.0↔1.04) and a glow-opacity pulse; layered on the existing 2px bob; frozen to the static happy pose under MediaQuery.disableAnimations (unchanged _syncIdle gate).
-
Step 1: Failing test — pump
DsCompanionCreature(type: DsCompanionType.star, stage: 1)withMediaQueryData(disableAnimations: false),pump(Duration(milliseconds: 650))(quarter idle period), assert the state's@visibleForTesting double debugIdleScalemoved off 1.0; and withdisableAnimations: trueit stays a fixed static value (no ticking). -
Step 2: Run — expect FAIL. Run:
cd packages/design_system && fvm flutter test test/graphics/ds_companion_creature_idle_test.dart -
Step 3: Implement — in the
AnimatedBuilderbuilder (wherebob/hopare computed), addfinal breathe = 1.0 + math.sin(_idle.value * math.pi) * 0.04;and wrap the painter output inTransform.scale(scale: breathe, ...); drive the glow alpha from the same_idle.value(lerp ±10%). Under reduced motion_idleis stopped atvalue = 0.35, sobreatheis a fixed >1.0 static pose. ExposedebugIdleScale => breathe. -
Step 4: Run — PASS + regen goldens:
cd packages/design_system && fvm flutter test --update-goldensthenfvm flutter test(full DS suite green; the companion body goldens shift by the static breathe scale — expected + committed). -
Step 5: Commit
feat(ds): companion idle breathing scale + glow pulse (reduced-motion-safe).
Task 3: App — CompanionRepository.loadForMember + ActiveIdentity_Cubit
Files:
- Modify:
app/lib/outside/repositories/companion/companion_repository.dart - Create:
app/lib/inside/blocs/active_identity/cubit.dart,.../state.dart - Test:
app/test/blocs/active_identity_cubit_test.dart
Interfaces — Produces:
-
CompanionRepository.loadForMember(String memberId) → Future<CompanionView?>— callsclient.getCompanion(memberId)(existing facade; RLS parental-read; NO mutation, NO_assertActorSelf). Returns null on no-session. -
ActiveIdentity_CubitwithActiveIdentity_State({required HouseholdMember? member, CompanionView? companionView, ActiveIdentity_Status status}). It watches the selected member + roster (reuse the streamsAccountSwitcherCubituses — read that cubit first) and on each active-member change loadsloadForMember(activeId). Keyed on the ACTIVE (selected) member. -
Step 1: Failing test — with a fake
CompanionRepository+ a controllable selected-member stream: active member A →state.member==A+companionView==A's view; switch to B → reload →companionView==B's view; anotCreatedview →companionView.isCreated==false. (Build the cubit mirroringAccountSwitcherCubit's wiring.) -
Step 2: Run — expect FAIL. Run:
cd app && fvm flutter test test/blocs/active_identity_cubit_test.dart -
Step 3: Implement
loadForMember(mirrorloadForCurrentMemberbut take the id, skip the auth→member resolution) + the cubit (subscribe to selected-member + roster; on change resolve the member +loadForMember; typed errors → a failure status that the avatar treats as emoji-fallback). Provide the cubit shell-wide inMainShellPage.wrappedRoutealongside the others. -
Step 4: Run — PASS.
-
Step 5: Commit
feat(app): ActiveIdentity_Cubit + loadForMember (active-member companion view).
Task 4: App — Identity_Avatar (companion-or-emoji resolver)
Files:
- Create:
app/lib/inside/routes/authenticated/shared/identity_avatar.dart - Test:
app/test/widget/identity_avatar_test.dart
Interfaces — Consumes: HouseholdMember, CompanionView?, DsCompanionCreature, companionColorFor, the existing member-emoji avatar widget (grep how member emoji avatars render on the roster rows and reuse it). Produces: Identity_Avatar({required HouseholdMember member, CompanionView? companionView, required double size}) → the companion creature when companionView != null && companionView.isCreated (and enabled for that member id via the shared-prefs effect), else the member's emoji avatar. Full-colour; used at ~24 (nav) and ~40 (top bar). Pure (no bloc reads inside).
-
Step 1: Failing widget test — companion branch: created
CompanionView→find.byType(DsCompanionCreature)findsOneWidget, emoji absent. Emoji branch:companionView == null(orisCreated==false) → member-emoji avatar present,DsCompanionCreatureabsent. Both atsize: 24. -
Step 2: Run — expect FAIL. Run:
cd app && fvm flutter test test/widget/identity_avatar_test.dart -
Step 3: Implement the resolver widget per the interface.
-
Step 4: Run — PASS.
-
Step 5: Commit
feat(app): Identity_Avatar — companion-when-configured, else emoji.
Task 5: App — Shell_AppBar (companion + viewing-as + balance); retire _EarnBrowseLabel
Files:
- Create:
app/lib/inside/routes/authenticated/shell/shell_app_bar.dart - Modify:
earn/page.dart,rewards_tab/page.dart(drop_EarnBrowseLabel, mountShell_AppBar);home+householdtop areas; retireshared/top_level_header.dart+shared/account_switcher_chip.dartusages. - Test:
app/test/widget/shell_app_bar_test.dart
Interfaces — Consumes: ActiveIdentity_Cubit, Identity_Avatar, DsTokenPill, the active member's balance (the source _EarnBrowseLabel used — state.selectedWallet). Produces: Shell_AppBar — a top row: Identity_Avatar(size: 40) (top-left, tappable → the companion sheet) + a "viewing {member}" cue (Strings.shellViewingMemberFor(name)) + DsTokenPill(amount: balance) trailing.
-
Step 1: Failing widget test — pump
Shell_AppBarwith a mockedActiveIdentity_Cubit(member "Bob", created companion, balance 11):Identity_Avatarpresent, "viewing Bob" text present,DsTokenPillshows 11; switch active member → avatar + label update. -
Step 2: Run — expect FAIL. Run:
cd app && fvm flutter test test/widget/shell_app_bar_test.dart -
Step 3: Implement
Shell_AppBar; addStrings.shellViewingMemberFor(String name); mount at the top of the four tab bodies (replace_EarnBrowseLabelon Earn/Rewards; add to Home/Household). Extract the companion sheet-opener fromcompanion_layer.dartinto a small reusable function tapped by the avatar. Delete_EarnBrowseLabel+ repoint its tests. -
Step 4: Run — PASS + repointed earn/rewards tests.
-
Step 5: Commit
feat(app): Shell_AppBar (companion + viewing-as + balance); retire _EarnBrowseLabel.
Task 6: App — split the companion layer (retire bottom scene; add backdrop ambient)
Files:
- Create:
app/lib/inside/routes/authenticated/shell/companion_ambient_layer.dart - Modify:
shell/page.dart(remove the bottomCompanionLayer; insert the ambient layer betweenDsAppBackdropandbody); retireshell/companion_layer.dart's bottom scene (keep only the reusable sheet-opener from Task 5). - Test:
app/test/widget/companion_layer_test.dart(repoint to the new structure).
Interfaces — Produces: Companion_Ambient_Layer — a pointer-transparent widget rendering DsCosmicDim(tier: ...) (the active member's dim tier via ActiveIdentity_Cubit + the existing ambient-intensity clamp) BEHIND the tab content and IN FRONT of DsAppBackdrop's houses/horizon. The creature no longer renders here; the "+N" float anchors near the top-left avatar (in Shell_AppBar on reactTick).
-
Step 1: Updated test — assert the ambient dim renders behind content, and the bottom-anchored creature scene is GONE (no
DsCompanionScenebottom-left); the creature lives inShell_AppBar(Task 5). -
Step 2: Run — expect FAIL (old bottom scene still asserted). Run:
cd app && fvm flutter test test/widget/companion_layer_test.dart -
Step 3: Implement —
Companion_Ambient_LayerrendersDsCosmicDim;shell/page.dartorder becomes[DsAppBackdrop, Companion_Ambient_Layer, DsAdaptiveScaffold(body)]. Remove the bottomCompanionLayer. Gate the ambient/avatar on the ACTIVE member's companion-enabled (per spec the display follows the lens); keep the tab set gated on the AUTHENTICATED member (unchanged). -
Step 4: Run — PASS.
-
Step 5: Commit
feat(app): split companion — backdrop ambient layer; retire bottom scene.
Task 7: App — nav More→Profile + route rename
Files:
- Modify:
shell/page.dart(destination lists adult+child: Profile slot withIdentity_Avatar+ short name; routesMoreRoute→ProfileRoute),routes/router.dart(registerProfileRoute), regenrouter.gr.dart. - Test:
app/test/widget/shell_nav_profile_test.dart
Interfaces — Consumes: Identity_Avatar, ActiveIdentity_Cubit, Strings. Produces: the 5th (adult) / 4th (child) nav destination is Profile: DsNavDestination(icon: Icons.person_outline, label: <ellipsized active member name>, avatar: Identity_Avatar(size: 24)); _kAdultRoutes + _kChildRoutes swap MoreRoute()→ProfileRoute() (index order preserved). Destinations become instance builders (from ActiveIdentity_Cubit state) instead of static const, so they react to the active member.
-
Step 1: Failing widget test — active member "Bob" (created companion): the last nav destination shows
DsCompanionCreature(notIcons.more_horiz), label "Bob" (ellipsized); tapping navigates toProfileRoute. -
Step 2: Run — expect FAIL.
-
Step 3: Implement — convert
_kAdult/_kChildDestinationsto instance methods taking the active member + companionView (wrap the scaffold in anActiveIdentity_CubitBlocBuilder), building the Profile destination with the avatar + ellipsized name. SwapMoreRoute()→ProfileRoute()in both route lists. AddProfileRoutetorouter.dartand regen:
cd app && fvm flutter pub run build_runner build --delete-conflicting-outputs --build-filter "lib/inside/routes/router.gr.dart"
git checkout HEAD -- $(git diff --name-only -- '*.g.dart' | grep -v 'router.gr.dart') 2>/dev/null || true
-
Step 4: Run — PASS.
-
Step 5: Commit
feat(app): nav More→Profile with active-member avatar icon.
Task 8: App — the Profile page (settings + switcher + subsections + logout)
Files:
- Create:
app/lib/inside/routes/authenticated/profile/page.dart(+widgets/); absorbmore/widgets/more_settings_tiles.dart. - Modify: retire
more/page.dart; retire the top-header logout. - Test:
app/test/widget/profile_page_test.dart+ settings-path repoints.
Interfaces — Consumes: the More settings tiles (Appearance, Accessibility, My Cosmos, About, Help, Join-with-code), the account-switcher (AccountSwitcherCubit list + selectMember, capability-gated), CurrentMemberRepository, the sign-out path (from the current top-header/Account). Produces: ProfilePage (@RoutePage()): (1) login-account header (authenticated self); (2) capability-gated account switcher list; (3) the settings rows (ex-More); (4) My Cosmos entry (active-member subsection); (5) Logout as the LAST row at the bottom.
-
Step 1: Failing widget test —
ProfilePagerenders the settings rows (Appearance/etc. keys preserved), the switcher list (for a capable viewer), a My Cosmos entry, and a bottom Logout row that invokes the sign-out path (verify via a mocked auth repository). -
Step 2: Run — expect FAIL.
-
Step 3: Implement
ProfilePagereusingMoreSettingsTiles(move underprofile/widgets/or import), adding the switcher list (mirrorhousehold_switcher_sheet/ the account-switcher menu) and the bottom Logout (move the sign-out affordance out of the top header/Account into here). Keep the My Cosmos →MyCosmosRoutenavigation. -
Step 4: Run — PASS.
-
Step 5: Commit
feat(app): Profile page — settings + account switcher + My Cosmos + logout-at-bottom.
Task 9: App — proxy read-only gating + flow tests + whole-suite green
Files:
- Modify:
cosmos/my_cosmos_page.dart+ the companion sheet consumers — hide/disable buy/equip/rename/edit when active ≠ self. - Create:
app/test/flows/shell_identity_test.dart - Test: full app + DS + SDK suites.
Interfaces — Consumes: ActiveIdentity_Cubit (active id) + CurrentMemberRepository.current (authenticated id). Produces: bool isProxied = activeId != authenticatedId; when true, MyCosmosPage's editors + the companion sheet's buy/equip/rename are hidden or disabled (read-only, with the read-only stage+balance still shown). Self → full edit (existing T9 My Cosmos tests still pass).
-
Step 1: Failing widget test —
MyCosmosPageactive≠self → type/color/adornment/rename controls absent/disabled, read-only stage+balance still show; active==self → controls present. -
Step 2: Run — expect FAIL.
-
Step 3: Implement the
isProxiedgate inmy_cosmos_page.dart(wrap the editor section) + the companion sheet (null outonBuy/onEquip/onRenameTapwhen proxied). Typed reads; no SDK change. -
Step 4: Write the flow test
shell_identity_test.dart— one EPIC ("Shell identity & proxy") + STORY, aflowTest('success')with screenshot steps: (a) top-bar shows the active member's companion + "viewing {member}" + balance; (b) switching the active member via Profile updates the top-bar avatar AND the Profile nav icon together; (c) Profile hosts settings + a bottom logout row; (d) proxied My Cosmos is read-only. Boundedpump()(looping companion animation — nopumpAndSettle). Followsign_in_test.dartcanonical +MocksContainer. -
Step 5: Run FULL suites + commit
cd packages/design_system && fvm flutter test # green (goldens updated T1/T2)
cd app && fvm flutter analyze lib # clean (pre-existing infos only)
cd app && fvm flutter test # green, baseline +867 not dropped
Repoint any test referencing the retired TopLevelHeader / account_switcher_chip / MoreRoute / bottom CompanionLayer to the new surfaces (migrate assertions, don't weaken). Commit feat(app): proxy read-only gating + shell-identity flow tests.
Self-Review
Spec coverage: companion split — creature→top-bar avatar (T5) + ambient→backdrop (T6); identity resolver companion-or-emoji (T4) used by top-bar (T5) + Profile nav icon (T7); active-member companion load (T3, presentation-only via existing getCompanion read); nav More→Profile both role sets + rail (T1 avatar support, T7 swap); Profile page login-frame + switcher + My Cosmos + logout-bottom (T8); authz invariant kept (tab set stays authenticated-keyed — T6/T7 notes) + proxy read-only rule (T9); top-bar context strip retiring _EarnBrowseLabel + top-header chip (T5); idle breathing/glow (T2); fallbacks/roles (T4 emoji fallback, T7 child nav). All spec sections mapped.
Placeholder scan: each task has concrete files, interfaces, test code, and commands; "mirror existing X" always names the exact reference. No TBD/TODO.
Type consistency: DsNavDestination.avatar: Widget? (T1)→T7; CompanionRepository.loadForMember(String)→Future<CompanionView?> (T3)→ActiveIdentity_Cubit (T3)→Identity_Avatar(member, companionView, size) (T4)→Shell_AppBar (T5) + Profile nav icon (T7); isProxied = activeId != authenticatedId (T9). Consistent.
Deviations flagged for the reviewer: (1) the ambient/avatar follow the ACTIVE member while the tab set stays AUTHENTICATED-keyed — confirm the CRITICAL ACTOR RULE holds (only display follows the lens). (2) getCompanion(memberId) for a proxied child relies on RLS parental-read + no _assertActorSelf (confirmed in exploration) so no SDK change — the reviewer should re-confirm no companion READ path added _assertActorSelf.