Files
Eventify-frontend/lib/screens/leaderboard_screen.dart
Sicherhaven c7e66756f9 fix: leaderboard empty on first open — decouple from loadAll()
LeaderboardScreen called loadAll() which uses Future.wait across 4 APIs.
If getDashboard() fails (empty user_id before auth), the entire batch
throws and leaderboard stays []. Switching districts worked because
setDistrict() calls getLeaderboard() directly.

- Add loadLeaderboard() to GamificationProvider — calls only getLeaderboard(),
  independent of dashboard/shop/achievements
- Add isLeaderboardLoading field with correct lifecycle in setDistrict/setTimePeriod
- LeaderboardScreen.initState now calls loadLeaderboard() instead of loadAll()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:13:56 +05:30

658 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/screens/leaderboard_screen.dart
// Dedicated Leaderboard screen for the Eventify Contributor Module.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../features/gamification/models/gamification_models.dart';
import '../features/gamification/providers/gamification_provider.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Design tokens
// ─────────────────────────────────────────────────────────────────────────────
const _blue = Color(0xFF0F45CF);
const _darkText = Color(0xFF1E293B);
const _subText = Color(0xFF94A3B8);
const _border = Color(0xFFE2E8F0);
const _lightBlueBg = Color(0xFFEFF6FF);
const _green = Color(0xFF10B981);
const _tierColors = <ContributorTier, Color>{
ContributorTier.BRONZE: Color(0xFFCD7F32),
ContributorTier.SILVER: Color(0xFFC0C0C0),
ContributorTier.GOLD: Color(0xFFFFD700),
ContributorTier.PLATINUM: Color(0xFFE5E4E2),
ContributorTier.DIAMOND: Color(0xFFB9F2FF),
};
const _districts = [
'Overall Kerala',
'Thiruvananthapuram',
'Kollam',
'Pathanamthitta',
'Alappuzha',
'Kottayam',
'Idukki',
'Ernakulam',
'Thrissur',
'Palakkad',
'Malappuram',
'Kozhikode',
'Wayanad',
'Kannur',
'Kasaragod',
];
class LeaderboardScreen extends StatefulWidget {
const LeaderboardScreen({super.key});
@override
State<LeaderboardScreen> createState() => _LeaderboardScreenState();
}
class _LeaderboardScreenState extends State<LeaderboardScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<GamificationProvider>();
if (provider.leaderboard.isEmpty) {
provider.loadLeaderboard();
}
});
}
@override
Widget build(BuildContext context) {
return Consumer<GamificationProvider>(
builder: (context, provider, _) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
bottom: false,
child: Column(
children: [
_buildAppBar(context),
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
// 1. User stat cards
if (provider.currentUserStats != null)
_buildStatCards(provider.currentUserStats!),
// 2. Time period toggle
const SizedBox(height: 16),
_buildPeriodToggle(provider),
// 3. District chips
const SizedBox(height: 16),
_buildDistrictChips(provider),
const SizedBox(height: 20),
// 45. Content area
_buildContent(provider),
const SizedBox(height: 100),
],
),
),
),
],
),
),
);
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// APP BAR
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildAppBar(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: _border.withValues(alpha: 0.5))),
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 20, color: _darkText),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Leaderboard',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _darkText),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _lightBlueBg,
borderRadius: BorderRadius.circular(12),
),
child: Consumer<GamificationProvider>(
builder: (_, p, __) => Text(
'${p.totalParticipants} contributors',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: _blue),
),
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// STAT CARDS (Your Rank / Total Points / Reward Cycle)
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildStatCards(CurrentUserStats stats) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
_statCard(
icon: Icons.emoji_events_rounded,
iconColor: const Color(0xFFEAB308),
label: 'Your Rank',
value: stats.rank > 0 ? '#${stats.rank}' : '--',
),
const SizedBox(width: 10),
_statCard(
icon: Icons.bolt_rounded,
iconColor: _blue,
label: 'Total Points',
value: stats.points > 0 ? '${stats.points}' : '--',
),
const SizedBox(width: 10),
_statCard(
icon: Icons.timer_outlined,
iconColor: _green,
label: 'Reward Cycle',
value: stats.rewardCycleDays > 0 ? '${stats.rewardCycleDays} Days' : '--',
),
],
),
);
}
Widget _statCard({
required IconData icon,
required Color iconColor,
required String label,
required String value,
}) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _border),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: Column(
children: [
Icon(icon, size: 22, color: iconColor),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _darkText),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 11, color: _subText, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
],
),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// PERIOD TOGGLE (All Time / This Month)
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildPeriodToggle(GamificationProvider provider) {
final current = provider.leaderboardTimePeriod;
return Center(
child: Container(
height: 44,
width: 280,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(22),
),
child: Stack(
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
alignment: current == 'all_time' ? Alignment.centerLeft : Alignment.centerRight,
child: Container(
width: 140,
height: 36,
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: _blue,
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(color: _blue.withValues(alpha: 0.3), blurRadius: 6, offset: const Offset(0, 2)),
],
),
),
),
Row(
children: [
_periodButton('All Time', 'all_time', current, provider),
_periodButton('This Month', 'this_month', current, provider),
],
),
],
),
),
);
}
Widget _periodButton(String label, String value, String current, GamificationProvider provider) {
final isActive = current == value;
return Expanded(
child: GestureDetector(
onTap: () => provider.setTimePeriod(value),
behavior: HitTestBehavior.opaque,
child: Center(
child: Text(
label,
style: TextStyle(
color: isActive ? Colors.white : _subText,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// DISTRICT CHIPS
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildDistrictChips(GamificationProvider provider) {
final selected = provider.leaderboardDistrict;
return SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _districts.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, index) {
final d = _districts[index];
final isSelected = selected == d;
return GestureDetector(
onTap: () => provider.setDistrict(d),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: isSelected ? _blue : Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: isSelected ? _blue : _border),
),
alignment: Alignment.center,
child: Text(
d,
style: TextStyle(
color: isSelected ? Colors.white : _darkText,
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
);
},
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// CONTENT AREA (loading / empty / podium + list)
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildContent(GamificationProvider provider) {
final leaderboard = provider.leaderboard;
final isLoading = provider.isLoading || provider.isLeaderboardLoading;
// Loading shimmer
if (isLoading) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
children: List.generate(5, (i) => _shimmerRow(i)),
),
);
}
// Empty state
if (leaderboard.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 80),
child: Center(
child: Column(
children: [
Icon(Icons.emoji_events_outlined, size: 56, color: Colors.grey.shade300),
const SizedBox(height: 16),
Text(
'No contributors yet',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey.shade400),
),
const SizedBox(height: 6),
Text(
'Be the first to contribute events!',
style: TextStyle(fontSize: 13, color: Colors.grey.shade400),
),
],
),
),
);
}
// Has data
final period = provider.leaderboardTimePeriod;
final hasPodium = leaderboard.length >= 3;
return Column(
children: [
// Podium (top 3)
if (hasPodium) _buildPodium(leaderboard.sublist(0, 3), period),
// Remaining entries (rank 4+, or all if < 3)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: hasPodium ? leaderboard.length - 3 : leaderboard.length,
separatorBuilder: (_, __) => Divider(height: 1, color: _border.withValues(alpha: 0.5)),
itemBuilder: (_, index) {
final entry = hasPodium ? leaderboard[index + 3] : leaderboard[index];
return _buildListRow(entry, period);
},
),
),
],
);
}
// ═══════════════════════════════════════════════════════════════════════════
// SHIMMER SKELETON
// ═══════════════════════════════════════════════════════════════════════════
Widget _shimmerRow(int index) {
return Padding(
padding: EdgeInsets.only(bottom: index < 4 ? 12 : 0),
child: Row(
children: [
_shimmerBox(24, 24, radius: 4),
const SizedBox(width: 12),
_shimmerBox(40, 40, radius: 20),
const SizedBox(width: 12),
Expanded(child: _shimmerBox(16, double.infinity)),
const SizedBox(width: 12),
_shimmerBox(16, 50),
],
),
);
}
Widget _shimmerBox(double height, double width, {double radius = 6}) {
return Container(
height: height,
width: width,
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(radius),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// PODIUM (Rank 13)
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildPodium(List<LeaderboardEntry> top3, String period) {
// Display order: [rank 2, rank 1, rank 3]
final first = top3[0];
final second = top3[1];
final third = top3[2];
return Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Rank 2 (left)
Expanded(child: _podiumColumn(second, period, height: 100, medal: '🥈')),
const SizedBox(width: 8),
// Rank 1 (center, tallest)
Expanded(child: _podiumColumn(first, period, height: 130, medal: '🥇')),
const SizedBox(width: 8),
// Rank 3 (right)
Expanded(child: _podiumColumn(third, period, height: 80, medal: '🥉')),
],
),
);
}
Widget _podiumColumn(LeaderboardEntry entry, String period, {required double height, required String medal}) {
final isMonthly = period == 'this_month';
final displayPts = isMonthly ? entry.monthlyPoints : entry.lifetimeEp;
final tierColor = _tierColors[entry.tier] ?? _tierColors[ContributorTier.BRONZE]!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Medal
Text(medal, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 4),
// Avatar
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: tierColor, width: 2.5),
boxShadow: [
BoxShadow(color: tierColor.withValues(alpha: 0.3), blurRadius: 8),
],
),
child: CircleAvatar(
radius: height == 130 ? 28 : 22,
backgroundColor: _lightBlueBg,
backgroundImage: entry.avatarUrl != null ? NetworkImage(entry.avatarUrl!) : null,
child: entry.avatarUrl == null
? Icon(Icons.person_rounded, color: _blue, size: height == 130 ? 28 : 22)
: null,
),
),
const SizedBox(height: 8),
// Name
Text(
entry.username,
style: TextStyle(
fontSize: height == 130 ? 13 : 12,
fontWeight: FontWeight.w600,
color: _darkText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
// Tier badge
_tierBadge(entry.tier, small: height != 130),
const SizedBox(height: 6),
// Podium block
Container(
height: height,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_blue.withValues(alpha: 0.08),
_blue.withValues(alpha: 0.03),
],
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
border: Border.all(color: _blue.withValues(alpha: 0.1)),
),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'#${entry.rank}',
style: TextStyle(
fontSize: height == 130 ? 22 : 18,
fontWeight: FontWeight.w800,
color: _blue,
),
),
const SizedBox(height: 4),
Text(
'$displayPts EP',
style: TextStyle(
fontSize: height == 130 ? 14 : 12,
fontWeight: FontWeight.w600,
color: _green,
),
),
],
),
),
],
);
}
// ═══════════════════════════════════════════════════════════════════════════
// LIST ROW (Rank 4+)
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildListRow(LeaderboardEntry entry, String period) {
final isMonthly = period == 'this_month';
final displayPts = isMonthly ? entry.monthlyPoints : entry.lifetimeEp;
final ptsLabel = isMonthly ? 'mo.' : 'EP';
final isMe = entry.isCurrentUser;
return Container(
color: isMe ? _lightBlueBg : Colors.transparent,
padding: EdgeInsets.symmetric(vertical: 12, horizontal: isMe ? 12 : 0),
child: Row(
children: [
// Rank
SizedBox(
width: 32,
child: Text(
'${entry.rank}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: entry.rank <= 3 ? _blue : _subText,
),
),
),
const SizedBox(width: 8),
// Avatar
CircleAvatar(
radius: 18,
backgroundColor: _lightBlueBg,
backgroundImage: entry.avatarUrl != null ? NetworkImage(entry.avatarUrl!) : null,
child: entry.avatarUrl == null
? const Icon(Icons.person_outline, color: _blue, size: 18)
: null,
),
const SizedBox(width: 10),
// Name + district
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.username,
style: TextStyle(
fontSize: 14,
fontWeight: isMe ? FontWeight.w600 : FontWeight.normal,
color: _darkText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (entry.district != null)
Text(
entry.district!,
style: const TextStyle(fontSize: 11, color: _subText),
),
],
),
),
// Tier badge
_tierBadge(entry.tier, small: true),
const SizedBox(width: 10),
// Points
Text(
'$displayPts $ptsLabel',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _green,
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// TIER BADGE
// ═══════════════════════════════════════════════════════════════════════════
Widget _tierBadge(ContributorTier tier, {bool small = false}) {
final color = _tierColors[tier] ?? _tierColors[ContributorTier.BRONZE]!;
final label = tierLabel(tier);
return Container(
padding: EdgeInsets.symmetric(horizontal: small ? 6 : 8, vertical: small ? 2 : 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.4), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: small ? 10 : 11,
fontWeight: FontWeight.w600,
color: tier == ContributorTier.PLATINUM || tier == ContributorTier.SILVER
? const Color(0xFF64748B)
: color.computeLuminance() > 0.5
? const Color(0xFF64748B)
: color,
),
),
);
}
}