DEV Community

kanta13jp1
kanta13jp1

Posted on

Building a Leaderboard with Flutter + Supabase (AI University #3)

Building a Leaderboard with Flutter + Supabase (AI University #3)

Third post in the AI University gamification series (streak → badges → leaderboard). Users are ranked by quiz correct count; top 10 show with public badges.

The PostgreSQL View

All ranking logic lives in a view — no sorting in Flutter:

CREATE VIEW ai_university_leaderboard AS
SELECT
  ROW_NUMBER() OVER (ORDER BY total_correct DESC, providers_studied DESC) AS rank,
  user_id,
  total_correct,
  providers_studied
FROM (
  SELECT
    user_id,
    COUNT(*) FILTER (WHERE quiz_correct = true) AS total_correct,
    COUNT(DISTINCT provider_id)                 AS providers_studied
  FROM ai_university_scores
  GROUP BY user_id
) sub;
Enter fullscreen mode Exit fullscreen mode

ROW_NUMBER() assigns ranks. Tiebreaker: providers_studied after total_correct.

Flutter — Fetch Top 10

final rows = await _supabase
    .from('ai_university_leaderboard')
    .select()
    .order('rank')
    .limit(10)
    .timeout(const Duration(seconds: 10));

final entries = (rows as List)
    .cast<Map<String, dynamic>>()
    .map(_LeaderboardEntry.fromMap)
    .where((e) => e.totalCorrect > 0 || e.providersStudied > 0)
    .toList();
Enter fullscreen mode Exit fullscreen mode

Filter out zero-score users with .where().

Parallel Queries: Badges + My Snapshot

// Start both futures before awaiting either
final badgesFuture     = _loadPublicBadges(leaderboardUserIds);
final mySnapshotFuture = _loadMySnapshot(_myUserId!);

// Await both — parallel DB calls
final badgesByUser = await badgesFuture;
final mySnapshot   = await mySnapshotFuture;
Enter fullscreen mode Exit fullscreen mode

Two DB calls run concurrently instead of sequentially.

Public Badges

Future<Map<String, List<_BadgeEntry>>> _loadPublicBadges(List<String> userIds) async {
  final rows = await _supabase
      .from('ai_university_badges')
      .select('user_id, badge_id, badge_name, icon_emoji')
      .in_('user_id', userIds)
      .eq('is_public', true);  // users can hide badges

  final result = <String, List<_BadgeEntry>>{};
  for (final row in rows as List) {
    result
      .putIfAbsent(row['user_id'] as String, () => [])
      .add(_BadgeEntry.fromMap(row));
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

One query fetches badges for all leaderboard users.

Rank Colors + Medals

static const _medals = {1: '🥇', 2: '🥈', 3: '🥉'};

Color get _rankColor => switch (rank) {
  1 => const Color(0xFFFFD700), // gold
  2 => const Color(0xFFC0C0C0), // silver
  3 => const Color(0xFFCD7F32), // bronze
  _ => const Color(0xFF6366F1), // indigo
};
Enter fullscreen mode Exit fullscreen mode

My Rank (Outside Top 10)

// Shows "You are currently ranked #42" even if not in top 10
final myRow = await _supabase
    .from('ai_university_leaderboard')
    .select()
    .eq('user_id', userId)
    .maybeSingle();

if (myRow != null) summaryTitle = 'You are currently ranked #${myRow['rank']}';
Enter fullscreen mode Exit fullscreen mode

Design Summary

Concern Approach
Rank calculation PostgreSQL ROW_NUMBER() — not Flutter
Parallel data Fire futures before awaiting
Privacy is_public column on badges
Zero-score filter .where() on client

The full gamification loop: study daily (streak) → earn achievements (badges) → climb the leaderboard (ranking).

Building in public: https://my-web-app-b67f4.web.app/

FlutterWeb #Supabase #buildinpublic #Gamification

Top comments (0)