Skip to main content

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_sdk facade. Presentation (app/lib/inside/**) never imports drift/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 — _assertActorSelf already enforces server-side).
  • Naming: NEW app classes use the gadfly underscore {Name}_Suffix convention (camel_case_types already relaxed); DS additions are Ds-prefixed PascalCase; models plain PascalCase; files snake_case. No brand strings in identifiers; copy via Strings.
  • 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 — extend DsNavDestination with an optional custom avatar widget (overrides the IconData when 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 — NEW Identity_Avatar (companion-when-configured, else member emoji), used by the top-bar + the Profile nav icon.
  • blocs/active_identity/cubit.dart (+ state.dart) — NEW ActiveIdentity_Cubit: resolves the active member (roster + selected id) and loads that member's CompanionView for the avatar; reacts to switches.
  • routes/authenticated/shell/shell_app_bar.dart — NEW Shell_AppBar (companion avatar top-left + "viewing {member}" + balance) mounted by the 4 tab pages; replaces per-page TopLevelHeader usage + _EarnBrowseLabel.
  • routes/authenticated/shell/page.dart — retire the bottom CompanionLayer; 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) — NEW ProfilePage (login-account frame + switcher + active-member subsections + logout bottom); absorbs more/widgets/more_settings_tiles.dart.
  • outside/repositories/companion/companion_repository.dart — add loadForMember(String memberId).
  • inside/routes/router.dart (+ regen router.gr.dart) — MoreRouteProfileRoute.
  • 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 (avatar param undefined). Run: cd packages/design_system && fvm flutter test test/templates/ds_nav_destination_avatar_test.dart

  • Step 3: Implement — add final Widget? avatar; to DsNavDestination (+ constructor param). Find where the scaffold builds each destination's icon (both the phone bottom bar and the rail): where it currently does Icon(destination.icon), use destination.avatar ?? Icon(destination.icon). For the phone bottom bar NavigationDestination(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 in ds_adaptive_scaffold.dart and 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) with MediaQueryData(disableAnimations: false), pump(Duration(milliseconds: 650)) (quarter idle period), assert the state's @visibleForTesting double debugIdleScale moved off 1.0; and with disableAnimations: true it 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 AnimatedBuilder builder (where bob/hop are computed), add final breathe = 1.0 + math.sin(_idle.value * math.pi) * 0.04; and wrap the painter output in Transform.scale(scale: breathe, ...); drive the glow alpha from the same _idle.value (lerp ±10%). Under reduced motion _idle is stopped at value = 0.35, so breathe is a fixed >1.0 static pose. Expose debugIdleScale => breathe.

  • Step 4: Run — PASS + regen goldens: cd packages/design_system && fvm flutter test --update-goldens then fvm 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?> — calls client.getCompanion(memberId) (existing facade; RLS parental-read; NO mutation, NO _assertActorSelf). Returns null on no-session.

  • ActiveIdentity_Cubit with ActiveIdentity_State({required HouseholdMember? member, CompanionView? companionView, ActiveIdentity_Status status}). It watches the selected member + roster (reuse the streams AccountSwitcherCubit uses — read that cubit first) and on each active-member change loads loadForMember(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; a notCreated view → companionView.isCreated==false. (Build the cubit mirroring AccountSwitcherCubit's wiring.)

  • Step 2: Run — expect FAIL. Run: cd app && fvm flutter test test/blocs/active_identity_cubit_test.dart

  • Step 3: Implement loadForMember (mirror loadForCurrentMember but 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 in MainShellPage.wrappedRoute alongside 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 CompanionViewfind.byType(DsCompanionCreature) findsOneWidget, emoji absent. Emoji branch: companionView == null (or isCreated==false) → member-emoji avatar present, DsCompanionCreature absent. Both at size: 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, mount Shell_AppBar); home + household top areas; retire shared/top_level_header.dart + shared/account_switcher_chip.dart usages.
  • 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_AppBar with a mocked ActiveIdentity_Cubit (member "Bob", created companion, balance 11): Identity_Avatar present, "viewing Bob" text present, DsTokenPill shows 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; add Strings.shellViewingMemberFor(String name); mount at the top of the four tab bodies (replace _EarnBrowseLabel on Earn/Rewards; add to Home/Household). Extract the companion sheet-opener from companion_layer.dart into 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 bottom CompanionLayer; insert the ambient layer between DsAppBackdrop and body); retire shell/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 DsCompanionScene bottom-left); the creature lives in Shell_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: ImplementCompanion_Ambient_Layer renders DsCosmicDim; shell/page.dart order becomes [DsAppBackdrop, Companion_Ambient_Layer, DsAdaptiveScaffold(body)]. Remove the bottom CompanionLayer. 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 with Identity_Avatar + short name; routes MoreRouteProfileRoute), routes/router.dart (register ProfileRoute), regen router.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 (not Icons.more_horiz), label "Bob" (ellipsized); tapping navigates to ProfileRoute.

  • Step 2: Run — expect FAIL.

  • Step 3: Implement — convert _kAdult/_kChildDestinations to instance methods taking the active member + companionView (wrap the scaffold in an ActiveIdentity_Cubit BlocBuilder), building the Profile destination with the avatar + ellipsized name. Swap MoreRoute()ProfileRoute() in both route lists. Add ProfileRoute to router.dart and 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/); absorb more/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 testProfilePage renders 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 ProfilePage reusing MoreSettingsTiles (move under profile/widgets/ or import), adding the switcher list (mirror household_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 → MyCosmosRoute navigation.

  • 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 testMyCosmosPage active≠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 isProxied gate in my_cosmos_page.dart (wrap the editor section) + the companion sheet (null out onBuy/onEquip/onRenameTap when proxied). Typed reads; no SDK change.

  • Step 4: Write the flow test shell_identity_test.dart — one EPIC ("Shell identity & proxy") + STORY, a flowTest('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. Bounded pump() (looping companion animation — no pumpAndSettle). Follow sign_in_test.dart canonical + 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.