Achievements — feature architecture
BUILT (Wave-1 A3, commit e085eea). Six achievement badges ship as pure read-only projections over existing client facade reads. No new schema, no new SDK verbs, no new deps. Badges are computed in
AchievementsCubitand rendered by the model-agnosticDsBadgeatom from the design system. The weekly streak badge (streak_starter) delegates to thecomputeWeeklyStreakCountprojection added in Wave-2 A4.
The loop in one idea
Badges are projections, not stored state. Every badge is recomputed fresh
from three parallel client reads each time AchievementsCubit.load is called.
There is no achievements table, no write path, and no badge persistence in
Phase 1. A badge is earned when the member's accumulated data crosses a
threshold — it stays earned as long as the underlying data satisfies the
criteria (all six thresholds are monotone: earned status never oscillates).
AchievementsRepository.loadForMember(memberId)
│
├── client.getCompletions(memberId) ──▶ completionCount
│ completionTimestamps (for streak)
├── client.getGoals(memberId, includeArchived: true)
│ ──▶ completedGoalsCount
└── client.watchLedger(memberId).first
──▶ lifetimeEarnedTokens
(earn-kind deltas only, monotone fold)
│
▼
AchievementsCubit.computeBadges(data, weeklyStreakCount)
│ pure — no I/O, exposed for unit tests
│
▼
AchievementsState { badges: [...earned first, ...locked], streakWeeks }
│
▼
DsBadge (design system atom — model-agnostic, emoji + progress bar)
1 · The 6 launch badges
| id | glyph | label | threshold source | threshold |
|---|---|---|---|---|
first_step | 👣 | First Step | completionCount | ≥ 1 |
helping_hands | 🤝 | Helping Hands | completionCount | ≥ 10 |
super_helper | ⭐ | Super Helper | completionCount | ≥ 25 |
goal_getter | 🎯 | Goal Getter | completedGoalsCount | ≥ 1 |
token_keeper | 💰 | Token Keeper | lifetimeEarnedTokens | ≥ 50 |
streak_starter | 🔥 | Streak Starter | weekly streak weeks | ≥ 4 |
Earned badges sort before locked badges in AchievementsState.badges; within
each group the original declaration order is preserved.
2 · CRITICAL correctness: completionCount sources from ChoreCompletion, not the ledger
This is the load-bearing invariant for the first five badges.
completionCount (and completionTimestamps) come from
client.getCompletions(memberId: memberId) — the ChoreCompletion rows —
NOT from ledger earn entries.
Why this matters: expectation chores (chores with kind = expectation) pay
zero tokens and therefore produce no LedgerEntry. If badge projection
were derived from earn entries, every expectation completion would be silently
excluded. A child who completes ten household expectations and zero bounty
chores would show 0 completions — the ledger is simply the wrong source for
counting effort.
first_step, helping_hands, and super_helper use completionCount (all
approved ChoreCompletion rows regardless of tokenValue). streak_starter
uses completionTimestamps from the same source for the same reason.
token_keeper is the one exception: it uses lifetimeEarnedTokens, a
monotone fold of LedgerEntryKind.earn deltas, because it explicitly measures
token earnings, not chore volume. A member who earned 60 and spent 35
(wallet = 25) still shows lifetimeEarnedTokens = 60 — the fold only counts
earn-kind rows and can never decrease on spend.
3 · Anti-wallpaper rule
Brief 2 defines the anti-wallpaper policy: criteria are ALWAYS visible on locked badges. A locked badge shows the unlock requirement so a member can see exactly what they are working toward. Hidden criteria make badges feel like participation trophies with no path forward — the design is explicitly anti-wallpaper.
This is enforced at the atom level in DsBadge: the criteria field is
rendered whenever earned == false, regardless of how the parent passes it.
AchievementBadge always carries a non-null criteria string (resolved from
Strings by the cubit). description (the earned-state text) is shown only
when earned == true.
DsBadge(earned: false) ──▶ shows criteria (unlock requirement)
shows progress bar (if progress != null)
does NOT show description
DsBadge(earned: true) ──▶ shows description (what was achieved)
does NOT show criteria
does NOT show progress bar
Progress bars are shown on count-based badges (first_step, helping_hands,
super_helper, token_keeper, streak_starter) using a linear fraction. The
goal_getter badge is boolean (no meaningful partial-progress fraction for "≥ 1
completed goal"), so _boolBadge passes no progress and the bar is omitted.
4 · Entities & relationships
AchievementsData has no domain rules — it carries raw counts. All badge
logic lives in AchievementsCubit.computeBadges (a static pure method exposed
without a leading underscore so unit tests call it directly without wiring the
cubit lifecycle).
5 · Data load — three parallel reads
The three Future.wait reads run in parallel. A failure on any read causes the
cubit to emit AchievementsStatus.failure; the UI hides the badges section on
failure (same policy as the streak section).
6 · DsBadge atom (design system)
DsBadge is model-agnostic: it accepts a plain glyph (String emoji or
IconData), label, earned, optional progress, optional description,
and optional criteria. It knows nothing about AchievementBadge.
Accessibility: a Semantics container wraps the badge and announces
"<label>, earned" or "<label>, locked, N% progress" so assistive
technology can navigate the badge grid without reading raw emoji Unicode names.
Emoji cannot be greyscaled in Flutter without a ColorFilter on the whole
subtree; locked badges use 0.65 opacity to communicate "not yet" while keeping
the glyph legible.
Celebratory unlock animation is deferred. DsBadge renders static earned
vs. locked states only. An unlock animation (e.g. a pop when a badge is newly
earned) requires persistence of the "previously earned" set — build-order item
9, deferred to Phase 2.
Deferred items
- Achievement persistence (build-order item 9) — no
achievements_earnedtable exists. Badges re-project from raw data on every load. Once persistence ships, an unlock animation and a "new badge" notification become feasible. - Badge unlock animation — deferred until persistence exists.
- Additional badge definitions — the 6 launch badges are the full launch set;
the framework supports adding new badges by adding cases to
computeBadges.
Relationship to other features
- Token economy —
lifetimeEarnedTokensfolds the sameLedgerEntryrows the token economy appends;token_keeperis the one badge that measures token earnings rather than chore volume. - Streaks —
AchievementsCubitdelegates weekly streak computation tocomputeWeeklyStreakCount; thestreak_starterbadge and thestreakWeeksdisplay inMemberStreakSectionshare the same computed value from a singlereferenceNowcall for consistency. - Member profile — badges and the streak section both live
on the member profile page, provided by a single
AchievementsCubitin the wrappingBlocProvider. - Celebration — the same
ChoreCompletionrows that drivecompletionCountare created by the submission flow the celebration overlay fires on; a future badge-unlock animation can hook off the celebration signal.