Member Profile — feature architecture
This page is the source of truth for the Member Profile feature. The ERD, state machines, and feature table below are what the flow tests, unit tests, and implementation are derived from. When the feature set changes, change this page first, then the tests, then the code.
The Member Profile (MemberProfilePage, route /member/:memberId) is the single
place to view and manage one household member: identity, traits, roles,
wallet, and personal goals. It is opened from the Household tab's member tiles.
It follows the one data path: Bloc → Repository → Client facade → Service → Adapter (domain rules live in the SDK service; the bloc/repos are thin).
Feature set
Status legend: ✅ built · 🔨 to build (SDK-ready, UI-only) · 🧩 needs SDK work · 📍 by design lives elsewhere.
| Area | Feature | Status | SDK surface | Notes |
|---|---|---|---|---|
| Identity | Display name | ✅ | HouseholdMember.displayName | header |
| Identity | Age tag | ✅ | HouseholdMember.ageAsOf(now) (DOB-derived; falls back to manual age) | DsTag — a DOB-only member now shows a derived age (DOB-M2) |
| Identity | Watch-only tag | ✅ | HouseholdMember.watchOnly | DsTag |
| Identity | Avatar colour | 🔨 | HouseholdMember.colorKey | present but avatar uses a flat surface colour |
| Identity | Home-room chip | 🔨 | HouseholdMember.homePlaceId + getPlaces() | bloc must load places + resolve name |
| Identity | Birthday (DOB) | ✅ | HouseholdMember.birthDate (+ ageAsOf / advisory isUnderCoppaAge) | CB-15 shipped a single birthDate: DateTime? floating calendar date (NOT birthMonth/birthDay); derives a non-staling age |
| Traits | Trait chips + starter suggestions | ✅ | updateMember(traits:) | rewhaven is ahead of the POC here |
| Roles | Role tags (admin/helper/member) | ✅ (read-only) | HouseholdMember.roles | display only |
| Roles | Role editing | 📍 | — | Members-management page (Profile → Account), per T7 P2 |
| Wallet | Buckets spend/save/give + total | ✅ | walletOf(memberId) | 3-bucket model (POC bank dropped by design) |
| Wallet | Transfer / sort sheet | 🧩 | — | no transferTokens on the facade — Money-sprint SDK work |
| Goals | List + progress bars | ✅ | getGoals(memberId:) + goalProgress(id) | |
| Goals | Add goal | ✅ | createGoal(...) | dialog |
| Goals | Archive goal | ✅ | archiveGoal(id) | |
| Goals | Request → approve lifecycle | ✅ | requestGoal / approveGoal | shipped as P2-G |
| Goals | dueLabel subtitle | 🔨 | Goal.dueLabel | populated but never rendered |
| Goals | Complete state ("Reached") | 🔨 | Goal.status == complete + completeGoal(id) | a complete goal looks identical to active |
| Goals | Edit goal | 🔨 | updateGoal(Goal) | only add/archive exist today |
| Goals | Emoji / image thumbnail | 🔨 | Goal.emoji + Goal.imageUrl | emoji is plain text; imageUrl ignored |
| Member mgmt | Add / remove / invite | 📍 | — | Members-management page, per T7 P2 |
| Engagement | Achievement badges (Wave-1 A3) | ✅ | AchievementsCubit — pure projection over facade reads | Rendered on this page; see features/achievements.md |
| Engagement | Weekly streak section (Wave-2 A4) | ✅ | StreakProjection — pure projection, zero new storage | Rendered on this page; see features/streaks.md |
| Engagement | Child timeline "My story" teaser + full page (Wave-2 A5) | ✅ | TimelineBloc / timeline repos — pure projection over ledger reads | Teaser rendered on this page; full page is a separate route; see features/child-timeline.md |
"Done 💯" = close the six 🔨 (UI-only) gaps. The two 🧩 items (token transfer, birthday) are tracked SDK work; the 📍 items are intentionally on the Members-management page.
ERD
Notes:
WALLETis 3 buckets (spend/save/give); the POC's unallocatedbankbucket was removed in the rebuild.wallet.totalis derived.- Goal progress is derived, not stored:
goalProgress(id)sums the member's earnLEDGER_ENTRYrows againsttargetTokens. The profile showsprogress / targetTokensand aDsProgressBar. - A member is linked to an account via the nullable
authUserId(shadow → claim).
State machines
Goal lifecycle
The economy service owns these transitions and their guards (rules live in the service, not the UI). The profile surfaces the actions per status.
Guards (enforced in EconomyService, throw DomainRuleException otherwise):
requestGoalrequires current statusactive.approveGoalrequires current statusrequestedand a parentalactingMemberId(MemberKind.isParental).completeGoalrequires current statusactive.
UI surface per status on a goal tile:
active→ Request (→ requested) + Edit + Archive; "Reached" when progress ≥ target.requested→ Approve (parental, when an approver is resolved) + "Awaiting approval" tag.complete→ "Reached" badge (read-only) + Archive.archived→ hidden from the default list (getGoalsfilters them).
MemberProfile bloc
Events (MemberProfileEvent): MemberProfileStarted,
MemberProfileTraitToggled, MemberProfileGoalAdded,
MemberProfileGoalRequested, MemberProfileGoalApproved,
MemberProfileGoalArchived — plus to add: MemberProfileGoalEdited,
MemberProfileGoalCompleted.
Data flow
MemberProfileBloc currently injects HouseholdRepository + WalletRepository
GoalsRepository. The home-room chip adds aPlacesRepositorydependency (or resolves the place name from the already-provided household roster load).
SDK surface (facade methods used)
| Method | Used for |
|---|---|
watchMembers() / updateMember(member) | load member, persist traits |
walletOf(memberId) | wallet buckets |
getGoals(memberId:) / goalProgress(goalId) | goals + progress |
createGoal / updateGoal / archiveGoal / restoreGoal | goal CRUD |
requestGoal / approveGoal / completeGoal | goal lifecycle |
getPlaces() | home-room name resolution |
Gap: there is no transferTokens on the facade — wallet
transfer/sort is future Money-sprint SDK work, not a UI-only task.
Test plan
Derive tests from the state machines + feature table. Anchors:
Unit / bloc — app/test/unit/member_profile_bloc_test.dart:
- load:
Started→readycarrying member, wallet, goals, progress; member not found →loadFailure. - trait toggle: optimistic add, rollback +
saveFailureon repo throw. - goal
requestGoal:active → requested, reflected in state ✅;saveFailureon reject ✅. - goal
approveGoal:requested → activewith the parental acting id. - goal
archiveGoal: removed from the visible list. - to add:
GoalEdited→updateGoalcalled with the patched goal;GoalCompleted→active → complete.
Flow / golden — app/test/flows/member_profile_test.dart:
- profile shows identity + wallet + traits + goals ✅.
- request goal → "Awaiting approval" tag ✅.
- approve goal uses the parental id ✅.
- to add: edit-goal sheet round-trips; a
completegoal shows "Reached";dueLabelrenders; home-room chip renders whenhomePlaceIdis set.
Widget — app/test/widget/:
- goal tile renders
dueLabel, the emoji/image thumbnail, and the complete ("Reached") state by status.
Build order — closing the gaps ("done 💯")
- Goal display polish (widget-only):
dueLabelsubtitle, "Reached" state forcomplete, emoji/imageUrlthumbnail. - Edit goal:
MemberProfileGoalEditedevent + handler (updateGoal) + an edit dialog reachable from the goal tile. - Home-room chip: load places in the bloc, resolve
homePlaceId→ name, show a chip in the identity header. - Avatar colour: map
colorKeyto the avatar in the identity header.
Deferred (need SDK first, tracked): wallet transfer/sort sheet (no facade
method yet). Birthday shipped (CB-15) as a single birthDate: DateTime?
floating calendar date (+ ageAsOf / advisory isUnderCoppaAge) — NOT the
once-planned birthMonth/birthDay pair — and the profile age tag is now
DOB-derived (DOB-M2). By design elsewhere: role editing and member add/remove
live on the Members-management page (Profile → Account).