Skip to main content

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 AchievementsCubit and rendered by the model-agnostic DsBadge atom from the design system. The weekly streak badge (streak_starter) delegates to the computeWeeklyStreakCount projection 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

idglyphlabelthreshold sourcethreshold
first_step👣First StepcompletionCount≥ 1
helping_hands🤝Helping HandscompletionCount≥ 10
super_helperSuper HelpercompletionCount≥ 25
goal_getter🎯Goal GettercompletedGoalsCount≥ 1
token_keeper💰Token KeeperlifetimeEarnedTokens≥ 50
streak_starter🔥Streak Starterweekly 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_earned table 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 economylifetimeEarnedTokens folds the same LedgerEntry rows the token economy appends; token_keeper is the one badge that measures token earnings rather than chore volume.
  • StreaksAchievementsCubit delegates weekly streak computation to computeWeeklyStreakCount; the streak_starter badge and the streakWeeks display in MemberStreakSection share the same computed value from a single referenceNow call for consistency.
  • Member profile — badges and the streak section both live on the member profile page, provided by a single AchievementsCubit in the wrapping BlocProvider.
  • Celebration — the same ChoreCompletion rows that drive completionCount are created by the submission flow the celebration overlay fires on; a future badge-unlock animation can hook off the celebration signal.