From d6d8ac6dbf29d9ffe9b7ad785571c3eb5f4c4467 Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Sat, 14 Mar 2026 13:57:34 +0530 Subject: [PATCH] feat: implement leaderboard and achievements tabs in contribute screen - Add Leaderboard tab with top 3 podium, time/district filters, and ranking table - Add Achievements tab with badge grid (locked/unlocked with progress bars) - Implement AnimatedSwitcher for smooth tab content transitions - Add demo data for leaderboard users and achievement badges - Responsive layout for mobile and desktop views Co-Authored-By: Claude Haiku 4.5 --- lib/screens/contribute_screen.dart | 507 ++++++++++++++++++++++++++++- 1 file changed, 503 insertions(+), 4 deletions(-) diff --git a/lib/screens/contribute_screen.dart b/lib/screens/contribute_screen.dart index 7618629..96e4421 100644 --- a/lib/screens/contribute_screen.dart +++ b/lib/screens/contribute_screen.dart @@ -595,9 +595,505 @@ class _ContributeScreenState extends State with SingleTickerPr } } + // ── Leaderboard state ── + int _leaderboardTimeFilter = 0; // 0 = All Time, 1 = This Month + int _leaderboardDistrictFilter = 0; // index into _districts + + static const List _districts = [ + 'Overall Kerala', + 'Thiruvananthapuram', + 'Kollam', + 'Pathanamthitta', + 'Alappuzha', + 'Kottayam', + 'Idukki', + 'Ernakulam', + 'Thrissur', + 'Palakkad', + 'Malappuram', + 'Kozhikode', + 'Wayanad', + 'Kannur', + 'Kasaragod', + ]; + + // Demo leaderboard data + static const List> _leaderboardData = [ + {'name': 'Annette Black', 'points': 4628, 'level': 'Legend', 'events': 156}, + {'name': 'Jerome Bell', 'points': 4518, 'level': 'Legend', 'events': 152}, + {'name': 'Theresa Webb', 'points': 4368, 'level': 'Legend', 'events': 148}, + {'name': 'Courtney Henry', 'points': 4279, 'level': 'Legend', 'events': 149}, + {'name': 'Cameron Williamson', 'points': 4150, 'level': 'Legend', 'events': 144}, + {'name': 'Brooklyn Simmons', 'points': 4033, 'level': 'Legend', 'events': 139}, + {'name': 'Leslie Alexander', 'points': 3914, 'level': 'Champion', 'events': 134}, + {'name': 'Jenny Wilson', 'points': 3783, 'level': 'Champion', 'events': 132}, + ]; + + // Demo achievements data + static const List> _achievementsData = [ + {'name': 'Newcomer', 'subtitle': 'First Event Posted', 'icon': Icons.star_outline, 'color': 0xFFDBEAFE, 'iconColor': 0xFF3B82F6, 'unlocked': true}, + {'name': 'Contributor', 'subtitle': '10th Event Posted within a month', 'icon': Icons.workspace_premium, 'color': 0xFFFEF9C3, 'iconColor': 0xFFEAB308, 'unlocked': true}, + {'name': 'On Fire!', 'subtitle': '3 Day Streak of logging in', 'icon': Icons.local_fire_department_outlined, 'color': 0xFFFFEDD5, 'iconColor': 0xFFF97316, 'unlocked': true, 'progress': 0.67}, + {'name': 'Verified', 'subtitle': 'Identity Verified successfully', 'icon': Icons.verified_outlined, 'color': 0xFFDCFCE7, 'iconColor': 0xFF22C55E, 'unlocked': true}, + {'name': 'Quality', 'subtitle': '5 Star Event Rating received', 'icon': Icons.lock_outline, 'color': 0xFFF1F5F9, 'iconColor': 0xFF94A3B8, 'unlocked': false}, + {'name': 'Community', 'subtitle': 'Referred 5 Friends to the platform', 'icon': Icons.people_outline, 'color': 0xFFE0E7FF, 'iconColor': 0xFF6366F1, 'unlocked': true, 'progress': 0.40}, + {'name': 'Expert', 'subtitle': 'Level 10 Reached in 3 months', 'icon': Icons.lock_outline, 'color': 0xFFF1F5F9, 'iconColor': 0xFF94A3B8, 'unlocked': false}, + {'name': 'Precision', 'subtitle': '100% Data Accuracy on all events', 'icon': Icons.lock_outline, 'color': 0xFFF1F5F9, 'iconColor': 0xFF94A3B8, 'unlocked': false}, + ]; + + Widget _buildLeaderboard(BuildContext context) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 18), + padding: const EdgeInsets.fromLTRB(0, 16, 0, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Time filter: All Time / This Month ── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildTimeToggle(theme), + ], + ), + ), + const SizedBox(height: 12), + + // ── District filter chips (horizontal scroll) ── + SizedBox( + height: 38, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _districts.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final active = i == _leaderboardDistrictFilter; + return GestureDetector( + onTap: () => setState(() => _leaderboardDistrictFilter = i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: active ? _primary : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: active ? _primary : Colors.grey.shade300), + ), + child: Text( + _districts[i], + style: TextStyle( + color: active ? Colors.white : Colors.grey.shade600, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ); + }, + ), + ), + const SizedBox(height: 24), + + // ── Podium (top 3) ── + _buildPodium(theme), + const SizedBox(height: 24), + + // ── Leaderboard table (rank 4+) ── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + SizedBox(width: 32, child: Text('RANK', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500))), + const SizedBox(width: 8), + Expanded(child: Text('USER', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500))), + SizedBox(width: 60, child: Text('POINTS', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500))), + const SizedBox(width: 8), + SizedBox(width: 68, child: Text('LEVEL', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500), textAlign: TextAlign.center)), + const SizedBox(width: 8), + SizedBox(width: 32, child: Text('EVENTS', style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Colors.grey.shade500), textAlign: TextAlign.center)), + ], + ), + ), + // Rows (rank 4+) + ...List.generate( + _leaderboardData.length - 3, + (i) => _buildLeaderboardRow(theme, i + 3), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTimeToggle(ThemeData theme) { + final labels = ['All Time', 'This Month']; + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(labels.length, (i) { + final active = i == _leaderboardTimeFilter; + return GestureDetector( + onTap: () => setState(() => _leaderboardTimeFilter = i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: active ? _primary : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + labels[i], + style: TextStyle( + color: active ? Colors.white : Colors.grey.shade600, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ); + }), + ), + ); + } + + Widget _buildPodium(ThemeData theme) { + if (_leaderboardData.length < 3) return const SizedBox.shrink(); + + final first = _leaderboardData[0]; // #1 + final second = _leaderboardData[1]; // #2 + final third = _leaderboardData[2]; // #3 + + // Podium colors + const goldColor = Color(0xFFFBBF24); + const silverColor = Color(0xFFD1D5DB); + const bronzeColor = Color(0xFFF97316); + + Widget podiumSlot(Map user, int rank, Color pillarColor, double pillarHeight, Color badgeColor) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Avatar with rank badge + Stack( + clipBehavior: Clip.none, + children: [ + CircleAvatar( + radius: rank == 1 ? 32 : 26, + backgroundColor: badgeColor.withOpacity(0.2), + child: Icon(Icons.person, size: rank == 1 ? 32 : 26, color: badgeColor), + ), + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: badgeColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + alignment: Alignment.center, + child: Text('$rank', style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w800)), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + user['name'] as String, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '${_formatNumber(user['points'] as int)} pts', + style: TextStyle(color: _primary, fontWeight: FontWeight.w600, fontSize: 12), + ), + const SizedBox(height: 6), + // Pillar + Container( + width: 80, + height: pillarHeight, + decoration: BoxDecoration( + color: pillarColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), + ), + ), + ], + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // #2 – left + Expanded(child: podiumSlot(second, 2, silverColor, 70, Colors.grey.shade500)), + const SizedBox(width: 8), + // #1 – center (tallest) + Expanded(child: podiumSlot(first, 1, goldColor, 100, goldColor)), + const SizedBox(width: 8), + // #3 – right + Expanded(child: podiumSlot(third, 3, bronzeColor, 55, bronzeColor)), + ], + ), + ); + } + + Widget _buildLeaderboardRow(ThemeData theme, int index) { + final user = _leaderboardData[index]; + final rank = index + 1; + final level = user['level'] as String; + + Color levelColor; + Color levelBg; + switch (level) { + case 'Legend': + levelColor = const Color(0xFF16A34A); + levelBg = const Color(0xFFDCFCE7); + break; + case 'Champion': + levelColor = const Color(0xFF9333EA); + levelBg = const Color(0xFFF3E8FF); + break; + default: + levelColor = Colors.grey; + levelBg = Colors.grey.shade100; + } + + return Container( + margin: const EdgeInsets.only(bottom: 2), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: Colors.grey.shade100)), + ), + child: Row( + children: [ + SizedBox(width: 32, child: Text('$rank', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: Colors.grey.shade700))), + const SizedBox(width: 8), + CircleAvatar( + radius: 18, + backgroundColor: Colors.grey.shade200, + child: Icon(Icons.person, size: 20, color: Colors.grey.shade500), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + user['name'] as String, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + width: 60, + child: Text( + '${_formatNumber(user['points'] as int)} pts', + style: TextStyle(color: _primary, fontWeight: FontWeight.w600, fontSize: 12), + ), + ), + const SizedBox(width: 8), + Container( + width: 68, + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: levelBg, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text(level, style: TextStyle(color: levelColor, fontWeight: FontWeight.w600, fontSize: 11)), + ), + const SizedBox(width: 8), + SizedBox( + width: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.calendar_today, size: 10, color: Colors.grey.shade400), + const SizedBox(width: 2), + Text('${user['events']}', style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + ], + ), + ), + ], + ), + ); + } + + String _formatNumber(int n) { + if (n >= 1000) { + return '${(n / 1000).toStringAsFixed(n % 1000 == 0 ? 0 : 0)},${(n % 1000).toString().padLeft(3, '0')}'; + } + return '$n'; + } + + // ── Achievements Tab ── + + Widget _buildAchievements(BuildContext context) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 18), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your Badges', + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // Badge grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.05, + ), + itemCount: _achievementsData.length, + itemBuilder: (context, i) => _buildBadgeCard(theme, _achievementsData[i]), + ), + ], + ), + ); + } + + Widget _buildBadgeCard(ThemeData theme, Map badge) { + final isUnlocked = badge['unlocked'] as bool; + final progress = badge['progress'] as double?; + final badgeColor = Color(badge['color'] as int); + final iconColor = Color(badge['iconColor'] as int); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade100), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon circle + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: badgeColor, + shape: BoxShape.circle, + ), + child: Icon( + badge['icon'] as IconData, + color: iconColor, + size: 22, + ), + ), + const Spacer(), + // Name + lock indicator + Row( + children: [ + Expanded( + child: Text( + badge['name'] as String, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + color: isUnlocked ? Colors.black87 : Colors.grey.shade400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (!isUnlocked) + Icon(Icons.lock_outline, size: 14, color: Colors.grey.shade400), + ], + ), + const SizedBox(height: 4), + Text( + badge['subtitle'] as String, + style: TextStyle( + fontSize: 11, + color: isUnlocked ? Colors.grey.shade600 : Colors.grey.shade400, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + // Progress bar if applicable + if (progress != null) ...[ + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: progress), + duration: const Duration(milliseconds: 800), + builder: (_, val, __) => LinearProgressIndicator( + value: val, + minHeight: 5, + valueColor: AlwaysStoppedAnimation(_primary), + backgroundColor: Colors.grey.shade200, + ), + ), + ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: Text( + '${(progress * 100).toInt()}%', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500, fontWeight: FontWeight.w600), + ), + ), + ], + ], + ), + ); + } + @override Widget build(BuildContext context) { - // The whole screen is scrollable — header is part of the normal scroll (not floating). + // Switch content based on active tab + Widget tabContent; + switch (_activeTab) { + case 1: + tabContent = _buildLeaderboard(context); + break; + case 2: + tabContent = _buildAchievements(context); + break; + default: + tabContent = Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: _buildForm(context), + ); + } + return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: SafeArea( @@ -607,9 +1103,12 @@ class _ContributeScreenState extends State with SingleTickerPr child: Column( children: [ _buildHeader(context), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: _buildForm(context), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: KeyedSubtree( + key: ValueKey(_activeTab), + child: tabContent, + ), ), const SizedBox(height: 36), ],