feat: achievements - evaluation, badges, profile grid - #29
Conversation
Data-only edit to the achievementDefs seed (was 10) and the matching threshold documented in the firestore schema, ahead of the Phase 7A achievements client work.
New client-side-only achievements slice, mirroring the elo_calculator
pattern:
- AchievementDefModel — the achievementDefs/{id} schema shape.
- AchievementEvaluator — pure, side-effect-free evaluation of all 6
seeded conditions (first_victory, win_streak, giant_slayer, explorer,
monthly_count) against a per-match context, write-once by construction
(never re-emits an id already in currentBadges). tournament_champion
is intentionally never evaluated here.
- AchievementRepository — bounded reads over achievementDefs.
- ARB keys for all 6 badge names/descriptions plus the profile grid and
unlock-reveal strings (EN + IT), the assets/badges/ block in
pubspec.yaml, and the 6 provided badge PNGs.
Each piece ships with its own unit test (evaluator covers every
condition's boundaries and the write-once rule).
…nsaction
confirmResult now returns the newly-earned achievementIds per player
(Map<uid, Set<achievementId>>) instead of void. MatchRepository builds
the AchievementEvaluator's context in _prepareConfirmation (reusing the
per-player confirmed-history read already done for streak/monthly/
explorer — no new query) and writes badges.{id} = Timestamp for every
player inside the SAME atomic transaction as the match/ELO/facility
writes (Phase 4 Transaction Rule), via SetOptions(merge: true).
Write-once is enforced end-to-end: the evaluator never returns an id
already present in the user's badges map, so the merge write can never
clobber an existing unlock timestamp. Achievement reads are cosmetic
(Spark-Tier Trust Boundary) and degrade to "no badges" on any failure
rather than failing the confirmation.
completeIfDecided now awards the winner the tournament_champion badge in the same commit as the completed/winnerUid write — the only place this condition is evaluated (never at match confirm). Write-once: the winner's badges map is read before any write, and the merge write is skipped when the id is already present, so a re-derived champion never overwrites an existing unlock timestamp.
ResultCubit.confirm() resolves the confirming user's own newly-earned ids (from confirmResult's new return value) to their AchievementDefModels and carries them as ResultState.newBadges. ResultSummaryView renders an inline reveal (badge PNG(s) + localized name, scale-in animation, medium haptic) when non-empty — no FCM, no local-notifications dependency, mirroring the established Spark-tier client-local alert pattern (queue- ready, challenge notification). Resolution is decorative: a failed defs read just skips the reveal, since the badge is already persisted.
ProfileCubit loads the achievementDefs list (best-effort, decorative — a failed read just hides the section) and ProfileScreen renders it as a grid: one art file per badge, earned full-color vs locked desaturated (ColorFilter greyscale matrix), driven by the seed's iconAsset field. Works unchanged for a foreign profile. Each tile is tappable and surfaces its detail as a system-message SnackBar (the reset-password idiom) instead of a permanent on-grid caption — the unlock date for an earned badge, or how to earn it for a locked one — keeping the grid compact. AchievementRepository is threaded through createRouter/ProfileScreen alongside the other per-tab repositories.
GridView.count's childAspectRatio forces every row to a fixed height — once the on-grid caption was replaced by the tap-to-reveal SnackBar, the row height no longer matched the (now shorter) content, leaving a gap under every badge. Swapped to a Wrap of fixed-width tiles, which sizes each row to its actual content instead of a computed aspect ratio.
…gate Favorite Victim and Black Beast only render once a head-to-head hits kFunStatMinMatchups (3). Rework the seeded win-streak matches to be all vs. the same opponent (Favorite Victim) and add two more losses vs. another (Black Beast, on top of the existing confirmed loss), so both fun-stat cards have seed data to demo on the profile screen.
The Wrap from the previous commit fixed the empty-space bug but regressed the grid to a centered cluster of fixed-width tiles instead of stretching across the card. Switched to GridView.builder with a SliverGridDelegateWithFixedCrossAxisCount: crossAxisCount keeps the 3 columns stretching to fill the full width like before, while a fixed mainAxisExtent (not childAspectRatio) sizes rows to the tile's actual content height, so the empty-space fix is kept.
ProfileCubit._loadUser() fetched users/{uid} one-shot at cubit creation.
Because StatefulShellRoute keeps each tab's branch (and its cubit)
alive across tab switches rather than rebuilding it, that fetch never
re-ran — so a newly-earned achievement badge (or any other user-doc
change) only appeared after a full app restart, when a fresh
ProfileCubit finally re-read the doc.
Switched to UserRepository.watchUser(uid), the same live-stream pattern
already used by FriendsCubit/AuthCubit, so state.user (and its badges
map) stays current for as long as the cubit is alive.
Models/conditions, client-side confirm-transaction evaluation, and the profile badge display all ship in this branch. Drops the "push notification" line — the client-local in-app reveal (no FCM) is the shipped behavior, called out as a deviation in the PR description.
ChristianPrendin
left a comment
There was a problem hiding this comment.
Solid, well-structured slice — pure evaluator mirroring EloCalculator, badge writes correctly folded into the existing atomic transactions, write-once respected, reads-before-writes honored in both transactions, i18n complete in both ARBs. Verified locally on the branch: flutter analyze clean, dart format reports 0 changes, and all 664 tests pass. The giant-slayer ELO input is the correct decayed pre-match snapshot (matches the match doc's eloBefore), and the switch to a live watchUser stream is the right fix for badges not appearing until restart.
One blocking issue and a couple of non-blocking notes.
Blocking — tournament_champion uses serverTimestamp(), which will crash the profile parse in the optimistic-write window.
The match-confirm path writes a concrete Timestamp.fromDate(now), but completeIfDecided writes FieldValue.serverTimestamp(). With the default ServerTimestampBehavior.none, the local latency-compensated snapshot exposes an unresolved server timestamp as null. UserModel.fromMap does (value as Timestamp).toDate() on every badge value (user_model.dart:96) — a non-nullable cast that throws on that null. Because THIS PR also converts ProfileCubit to a live watchUser stream, the champion's own Profile tab (kept alive under StatefulShellRoute) now receives that optimistic snapshot; the throw propagates through watchUser's .map to ProfileCubit._onError, flipping the profile to an error state until the server resolves and re-emits. The tournament test misses this because fake_cloud_firestore resolves serverTimestamp() eagerly and reads the raw map rather than going through UserModel.fromMap.
Fix: write a concrete Timestamp here too (e.g. accept an injectable now like confirmResult does, or Timestamp.now()) for consistency with the match path — or, defensively, make UserModel.fromMap tolerate a pending/null badge value (value is Timestamp ? value.toDate() : ...).
Non-blocking — match-path write-once read is non-transactional. In confirmResult, currentBadges is read outside the transaction (_prepareBadgeInputs), whereas the tournament path reads it via tx.get. Two concurrent confirmations for the same user could both pass the write-once check and re-stamp the timestamp. Cosmetic-only impact (the id is still present), but worth a comment noting the asymmetry.
Non-blocking — Firestore index. The per-player history query (where('playerUids', arrayContains: uid).orderBy('startedAt', desc)) needs a composite index deployed; without it the whole eval silently degrades to "no badges" via the catch. Confirm the index exists in the project config so achievements actually fire in the deployed app.
Nit — app_router.dart import ordering: achievement_repository is added after challenge_repository (should sort before it). flutter analyze doesn't flag it, so purely cosmetic.
Address PR 29 review: - completeIfDecided writes Timestamp.fromDate(now) instead of serverTimestamp(), keeping the optimistic snapshot parseable so the champion's Profile tab doesn't flip to an error state mid-write. - UserModel.fromMap tolerates an unresolved/null badge value defensively. - Document the match-path non-transactional write-once read asymmetry. - Sort achievement_repository import before challenge_repository.
ChristianPrendin
left a comment
There was a problem hiding this comment.
Good job frank
Description
Implements the Phase 7A Achievements feature end-to-end, client-side only per the Spark-Tier Trust Boundary (no Cloud Functions):
AchievementDefModel/AchievementRepository— bounded reads overachievementDefs.AchievementEvaluator— pure, side-effect-free evaluator (mirrorsEloCalculator) for all 6 conditions:first_victory,win_streak,giant_slayer,explorer,monthly_count. Thresholds are read off each def, never hardcoded.MatchRepository.confirmResultnow evaluates and persists newly-earned badges (users/{uid}.badges) in the SAME atomic transaction as the match/ELO/facility writes, and returns the newly-earned ids per player instead ofvoid.TournamentRepository.completeIfDecidedawardstournament_championto the winner in the same commit as the completion write — the only place that condition is evaluated.badgesmap, so themergewrites can never clobber an existing unlock timestamp. Covered by dedicated tests (re-confirming/re-completing preserves the original timestamp).ColorFiltergreyscale matrix) — one art file per badge, driven by the seed'siconAsset. Tapping a badge surfaces its detail (unlock date, or how to unlock it) as a system-message SnackBar — the reset-password idiom — instead of a permanent on-grid caption, so the grid stays compact.workaholicthreshold bumped 10→15 per spec (seed + schema doc updated together).Related Task
Shared Contracts
MatchRepository.confirmResult's return type changedFuture<void>→Future<Map<String, Set<String>>>(newly-earned achievement ids per player uid), and the confirm transaction now also writesusers/{uid}.badges. The tournament-completion transaction (completeIfDecided) likewise now writesusers/{uid}.badgesfor the winner.users/{uid}.badgesis an existing locked schema field (brain/firestore_schema.md) — no new field, no new collection.facilities/{id}.status/.currentMatchId)facilities/{id}/queue/{uid}.position)Shared Files
app_router.dart(threadedAchievementRepositorythrough toProfileScreen, same pattern as the other per-tab repositories) and both ARB files (6 new badge name/desc keys + grid/reveal strings, EN+IT). Opened same day — please ping Abdullah/Christian.Breaking Changes
MatchRepository.confirmResult({required matchId, now})return type changed fromFuture<void>toFuture<Map<String, Set<String>>>(uid → newly-earned achievement ids). Its only caller,ResultCubit.confirm(), is already updated in this PR. Any other in-flight branch callingconfirmResultwill need a one-line update at the call site (the return value can simply be ignored if unused).Checklist
developand rebased ondevelopbefore opening this PR (no merge commits).dart formatapplied).flutter analyzeandflutter testlocally, OR the GitHub Actions check on this PR has passed).ScoringStrategycontract remains unmodified. — N/A, no new sport.ResultCubit/ProfileCubitextended).availabilityDisclaimerShortARB key. — N/A, no availability UI in this PR.Deviations (called out per the task spec)
_prepareConfirmationalready performs for win-streak/monthly (no new query). A denormalizedfacilitiesPlayedfield on the user doc was considered and declined — the shared history read already yields the set, and a new field would violate the locked schema.TournamentRepository.completeIfDecided, the tournament-completion path, not at match confirm.docs/design/doesn't cover it — precedent: the map recenter button, Phase 3). Extends the stats-grid card idiom; each badge is tappable and reveals its detail as a system-message SnackBar rather than a permanent on-grid caption, keeping the grid compact.UI Changes
docs/design/screenshots/profile.pngdocs/design/screenshots/result.png