Skip to content

feat: achievements - evaluation, badges, profile grid - #29

Merged
M1RK02 merged 12 commits into
developfrom
feature/A-achievements
Jul 9, 2026
Merged

feat: achievements - evaluation, badges, profile grid#29
M1RK02 merged 12 commits into
developfrom
feature/A-achievements

Conversation

@M1RK02

@M1RK02 M1RK02 commented Jul 9, 2026

Copy link
Copy Markdown
Owner

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 over achievementDefs.
  • AchievementEvaluator — pure, side-effect-free evaluator (mirrors EloCalculator) for all 6 conditions: first_victory, win_streak, giant_slayer, explorer, monthly_count. Thresholds are read off each def, never hardcoded.
  • MatchRepository.confirmResult now 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 of void.
  • TournamentRepository.completeIfDecided awards tournament_champion to the winner in the same commit as the completion write — the only place that condition is evaluated.
  • Write-once end-to-end: the evaluator never re-emits an id already present in a user's badges map, so the merge writes can never clobber an existing unlock timestamp. Covered by dedicated tests (re-confirming/re-completing preserves the original timestamp).
  • Client-local unlock reveal on the match summary screen (badge PNG + name + haptic) — no FCM/local-notifications, mirroring the existing queue-ready/challenge-alert pattern.
  • Tappable achievement badge grid on the profile screen: earned (full-colour) vs. locked (desaturated via ColorFilter greyscale matrix) — one art file per badge, driven by the seed's iconAsset. 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.
  • Seed: workaholic threshold bumped 10→15 per spec (seed + schema doc updated together).

Related Task

  • Vertical: A
  • Phase: 7 — Polish, Testing & Ship (🟡 Strong — Achievements)
  • Reviewer: @ChristianPrendin

Shared Contracts

  • None
  • Match document shape — not the doc shape itself, but MatchRepository.confirmResult's return type changed Future<void>Future<Map<String, Set<String>>> (newly-earned achievement ids per player uid), and the confirm transaction now also writes users/{uid}.badges. The tournament-completion transaction (completeIfDecided) likewise now writes users/{uid}.badges for the winner. users/{uid}.badges is an existing locked schema field (brain/firestore_schema.md) — no new field, no new collection.
  • Facility status field (facilities/{id}.status / .currentMatchId)
  • Queue position write (facilities/{id}/queue/{uid}.position)

Shared Files

  • This PR does not touch them, OR it was opened the same day the changes were made and the other two devs were pinged.
    • Touches app_router.dart (threaded AchievementRepository through to ProfileScreen, 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

  • No
  • Yes (describe what changed and who is affected below)
    • MatchRepository.confirmResult({required matchId, now}) return type changed from Future<void> to Future<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 calling confirmResult will need a one-line update at the call site (the return value can simply be ignored if unused).

Checklist

  • Branched off develop and rebased on develop before opening this PR (no merge commits).
  • My code follows the Dart style guide (dart format applied).
  • I have not introduced any hardcoded user-facing strings (used ARB files if applicable).
  • CI pipeline is green (I have run flutter analyze and flutter test locally, OR the GitHub Actions check on this PR has passed).
  • If this PR adds a new sport, the ScoringStrategy contract remains unmodified. — N/A, no new sport.
  • If this PR introduces a new Cubit, it emits an initial state synchronously. — N/A, no new Cubit (existing ResultCubit/ProfileCubit extended).
  • Firestore writes affecting ELO or queue position use atomic transactions. — N/A directly, but the new badge writes ride the existing ELO/completion transactions rather than a separate write, per the Phase 4 Transaction Rule.
  • If this PR shows facility availability ("free"/"empty" status), the disclaimer footer is present via the availabilityDisclaimerShort ARB key. — N/A, no availability UI in this PR.

Deviations (called out per the task spec)

  • Client-local unlock, no push: the task board literally says "Push notification on new achievement unlock." Per the established Spark-tier pattern (no server, no targeted FCM sender), the unlock is a client-local in-app reveal instead, exactly like the queue-ready alert and the challenge notification.
  • Explorer condition: derived from the same per-player confirmed-history read _prepareConfirmation already performs for win-streak/monthly (no new query). A denormalized facilitiesPlayed field 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.
  • tournament_champion: shipped in full (not left as a TODO) — awarded in TournamentRepository.completeIfDecided, the tournament-completion path, not at match confirm.
  • Badge grid interaction: no dedicated design mockup exists for the achievements section (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

Implemented Design reference
IMG_5508 docs/design/screenshots/profile.png
IMG_5507 docs/design/screenshots/result.png

M1RK02 added 9 commits July 9, 2026 15:54
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.
@M1RK02 M1RK02 changed the title feat: Achievements (Phase 7A) — evaluation, badges, profile grid feat: achievements - evaluation, badges, profile grid Jul 9, 2026
@M1RK02
M1RK02 requested a review from ChristianPrendin July 9, 2026 14:37
M1RK02 added 2 commits July 9, 2026 16:40
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 ChristianPrendin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/lib/repositories/tournament_repository.dart Outdated
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 ChristianPrendin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job frank

@M1RK02
M1RK02 merged commit 5df1d4d into develop Jul 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants