From c7e66756f95eddee4038e42e5a1a1143595e04e1 Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Wed, 8 Apr 2026 00:13:56 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20leaderboard=20empty=20on=20first=20open?= =?UTF-8?q?=20=E2=80=94=20decouple=20from=20loadAll()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../providers/gamification_provider.dart | 28 + lib/screens/leaderboard_screen.dart | 657 ++++++++++++++++++ 2 files changed, 685 insertions(+) create mode 100644 lib/screens/leaderboard_screen.dart diff --git a/lib/features/gamification/providers/gamification_provider.dart b/lib/features/gamification/providers/gamification_provider.dart index e0ed173..3ecf0c0 100644 --- a/lib/features/gamification/providers/gamification_provider.dart +++ b/lib/features/gamification/providers/gamification_provider.dart @@ -22,6 +22,7 @@ class GamificationProvider extends ChangeNotifier { String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month' bool isLoading = false; + bool isLeaderboardLoading = false; String? error; // TTL guard — prevents redundant API calls from multiple screens @@ -80,12 +81,36 @@ class GamificationProvider extends ChangeNotifier { } } + // --------------------------------------------------------------------------- + // Load only leaderboard data — safe to call independently of loadAll(). + // Used by LeaderboardScreen so a dashboard/shop failure doesn't blank the list. + // --------------------------------------------------------------------------- + Future loadLeaderboard() async { + if (isLeaderboardLoading) return; + isLeaderboardLoading = true; + notifyListeners(); + try { + final response = await _service.getLeaderboard( + district: leaderboardDistrict, + timePeriod: leaderboardTimePeriod, + ); + leaderboard = response.entries; + currentUserStats = response.currentUser; + totalParticipants = response.totalParticipants; + } catch (e) { + error = userFriendlyError(e); + } + isLeaderboardLoading = false; + notifyListeners(); + } + // --------------------------------------------------------------------------- // Change district filter // --------------------------------------------------------------------------- Future setDistrict(String district) async { if (leaderboardDistrict == district) return; leaderboardDistrict = district; + isLeaderboardLoading = true; notifyListeners(); try { final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod); @@ -95,6 +120,7 @@ class GamificationProvider extends ChangeNotifier { } catch (e) { error = userFriendlyError(e); } + isLeaderboardLoading = false; notifyListeners(); } @@ -104,6 +130,7 @@ class GamificationProvider extends ChangeNotifier { Future setTimePeriod(String period) async { if (leaderboardTimePeriod == period) return; leaderboardTimePeriod = period; + isLeaderboardLoading = true; notifyListeners(); try { final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period); @@ -113,6 +140,7 @@ class GamificationProvider extends ChangeNotifier { } catch (e) { error = userFriendlyError(e); } + isLeaderboardLoading = false; notifyListeners(); } diff --git a/lib/screens/leaderboard_screen.dart b/lib/screens/leaderboard_screen.dart new file mode 100644 index 0000000..810755a --- /dev/null +++ b/lib/screens/leaderboard_screen.dart @@ -0,0 +1,657 @@ +// 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.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 createState() => _LeaderboardScreenState(); +} + +class _LeaderboardScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = context.read(); + if (provider.leaderboard.isEmpty) { + provider.loadLeaderboard(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Consumer( + 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), + + // 4–5. 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( + 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 1–3) + // ═══════════════════════════════════════════════════════════════════════════ + Widget _buildPodium(List 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, + ), + ), + ); + } +}