Files
Eventify-frontend/lib/screens/contribute_screen.dart
Sicherhaven d6d8ac6dbf 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 <noreply@anthropic.com>
2026-03-14 13:57:34 +05:30

1121 lines
42 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/contribute_screen.dart
import 'dart:io';
import '../core/app_decoration.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class ContributeScreen extends StatefulWidget {
const ContributeScreen({Key? key}) : super(key: key);
@override
State<ContributeScreen> createState() => _ContributeScreenState();
}
class _ContributeScreenState extends State<ContributeScreen> with SingleTickerProviderStateMixin {
// Primary accent used for buttons / active tab (kept as a single constant)
static const Color _primary = Color(0xFF0B63D6);
// single corner radius to use everywhere
static const double _cornerRadius = 18.0;
// Form controllers
final TextEditingController _titleCtl = TextEditingController();
final TextEditingController _locationCtl = TextEditingController();
final TextEditingController _organizerCtl = TextEditingController();
final TextEditingController _descriptionCtl = TextEditingController();
DateTime? _selectedDate;
String _selectedCategory = 'Music';
// Image pickers
final ImagePicker _picker = ImagePicker();
XFile? _coverImageFile;
XFile? _thumbImageFile;
bool _submitting = false;
// Tab state: 0 = Contribute, 1 = Leaderboard, 2 = Achievements
int _activeTab = 0;
// Example progress value (0..1)
double _progress = 0.45;
// A few category options
final List<String> _categories = ['Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community'];
@override
void dispose() {
_titleCtl.dispose();
_locationCtl.dispose();
_organizerCtl.dispose();
_descriptionCtl.dispose();
super.dispose();
}
Future<void> _pickCoverImage() async {
try {
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
if (picked != null) setState(() => _coverImageFile = picked);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick image: $e')));
}
}
Future<void> _pickThumbnailImage() async {
try {
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
if (picked != null) setState(() => _thumbImageFile = picked);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick image: $e')));
}
}
Future<void> _pickDate() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? now,
firstDate: DateTime(now.year - 2),
lastDate: DateTime(now.year + 3),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(colorScheme: ColorScheme.light(primary: _primary)),
child: child!,
);
},
);
if (picked != null) setState(() => _selectedDate = picked);
}
Future<void> _submit() async {
if (_titleCtl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter an event title')));
return;
}
setState(() => _submitting = true);
// simulate work
await Future.delayed(const Duration(milliseconds: 800));
setState(() => _submitting = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Submitted for verification (demo)')));
_clearForm();
}
}
void _clearForm() {
_titleCtl.clear();
_locationCtl.clear();
_organizerCtl.clear();
_descriptionCtl.clear();
_selectedDate = null;
_selectedCategory = _categories.first;
_coverImageFile = null;
_thumbImageFile = null;
setState(() {});
}
// ---------- UI Builders ----------
Widget _buildHeader(BuildContext context) {
final theme = Theme.of(context);
// header uses AppDecoration.blueGradient for background (project-specific)
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 32, 20, 24),
decoration: AppDecoration.blueGradient.copyWith(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(_cornerRadius),
bottomRight: Radius.circular(_cornerRadius),
),
// subtle shadow only
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 4)),
],
),
child: SafeArea(
bottom: false,
child: Column(
children: [
// increased spacing to create a breathable layout
const SizedBox(height: 6),
// Title & subtitle (centered) — smaller title weight, clearer hierarchy
Text(
'Contributor Dashboard',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 20,
),
),
const SizedBox(height: 12), // more space between title & subtitle
Text(
'Track your impact, earn rewards, and climb the ranks!',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white.withOpacity(0.92), fontSize: 13, height: 1.35),
),
const SizedBox(height: 20), // more space before tabs
// Pill-style segmented tabs (animated active) — slimmer / minimal
_buildSegmentedTabs(context),
const SizedBox(height: 18), // comfortable spacing before contributor card
// Contributor level card — lighter, soft border, thinner progress
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.08), // slightly lighter than before
borderRadius: BorderRadius.circular(_cornerRadius - 2),
border: Border.all(color: Colors.white.withOpacity(0.09)), // soft border
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Contributor Level',
style: theme.textTheme.titleSmall?.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text('Start earning rewards by contributing!', style: theme.textTheme.bodySmall?.copyWith(color: Colors.white70)),
const SizedBox(height: 12),
Row(
children: [
Expanded(
// animated progress using TweenAnimationBuilder
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _progress),
duration: const Duration(milliseconds: 700),
builder: (context, value, _) => ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: value,
minHeight: 6, // thinner progress bar
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
backgroundColor: Colors.white24,
),
),
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Text('Explorer', style: theme.textTheme.bodySmall?.copyWith(color: Colors.white, fontWeight: FontWeight.w600)),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('30 pts', style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white, fontWeight: FontWeight.w600)),
Text('Next: Enthusiast (50 pts)', style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70)),
],
),
],
),
),
],
),
),
);
}
/// Bouncy spring curve matching web CSS: cubic-bezier(0.37, 1.95, 0.66, 0.56)
static const Curve _bouncyCurve = Cubic(0.37, 1.95, 0.66, 0.56);
/// Tab icons for each tab
static const List<IconData> _tabIcons = [
Icons.edit_outlined,
Icons.emoji_events_outlined,
Icons.workspace_premium_outlined,
];
Widget _buildSegmentedTabs(BuildContext context) {
final tabs = ['Contribute', 'Leaderboard', 'Achievements'];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 10),
child: LayoutBuilder(
builder: (context, constraints) {
final containerWidth = constraints.maxWidth;
// 6px padding on each side of the container
const double containerPadding = 6.0;
final innerWidth = containerWidth - (containerPadding * 2);
final tabWidth = innerWidth / tabs.length;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
height: 57,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.2)),
),
child: Stack(
children: [
// ── Sliding glider ──
AnimatedPositioned(
duration: const Duration(milliseconds: 500),
curve: _bouncyCurve,
left: containerPadding + (_activeTab * tabWidth),
top: containerPadding,
width: tabWidth,
height: 57 - (containerPadding * 2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.10),
blurRadius: 15,
offset: const Offset(0, 4),
),
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
),
),
// ── Tab labels ──
Padding(
padding: const EdgeInsets.all(containerPadding),
child: Row(
children: List.generate(tabs.length, (i) {
final active = i == _activeTab;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _activeTab = i),
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animated icon: only shows for active tab
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: active
? Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(
_tabIcons[i],
size: 15,
color: _primary,
),
)
: const SizedBox.shrink(),
),
Flexible(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
style: TextStyle(
color: active ? _primary : Colors.white.withOpacity(0.7),
fontWeight: FontWeight.w600,
fontSize: 14,
fontFamily: Theme.of(context).textTheme.bodyMedium?.fontFamily,
),
child: Text(
tabs[i],
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
),
);
}),
),
),
],
),
),
);
},
),
);
}
Widget _buildForm(BuildContext ctx) {
final theme = Theme.of(ctx);
return Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 18),
padding: const EdgeInsets.fromLTRB(18, 20, 18, 28),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
borderRadius: BorderRadius.circular(_cornerRadius),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 12, offset: const Offset(0, 6))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// sheet title
Center(
child: Column(
children: [
Text('Contribute an Event', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('Share local events. Earn points for every verified submission!',
textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
],
),
),
const SizedBox(height: 18),
// small helper button
Center(
child: OutlinedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Want to edit an existing event? (demo)')));
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_cornerRadius - 6)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
),
child: const Text('Want to edit an existing event?'),
),
),
const SizedBox(height: 18),
// Event Title
Text('Event Title', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
_roundedTextField(controller: _titleCtl, hint: 'e.g. Local Food Festival'),
const SizedBox(height: 14),
// Category
Text('Category', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedCategory,
isExpanded: true,
items: _categories.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
onChanged: (v) => setState(() => _selectedCategory = v ?? _selectedCategory),
icon: const Icon(Icons.keyboard_arrow_down),
),
),
),
const SizedBox(height: 14),
// Date
Text('Date', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
GestureDetector(
onTap: _pickDate,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
child: Text(_selectedDate == null ? 'Select date' : '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}', style: theme.textTheme.bodyMedium),
),
),
const SizedBox(height: 14),
// Location
Text('Location', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
_roundedTextField(controller: _locationCtl, hint: 'e.g. City Park, Calicut'),
const SizedBox(height: 14),
// Organizer
Text('Organizer Name', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
_roundedTextField(controller: _organizerCtl, hint: 'Individual or Organization Name'),
const SizedBox(height: 14),
// Description
Text('Description', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
TextField(
controller: _descriptionCtl,
minLines: 4,
maxLines: 6,
decoration: InputDecoration(
hintText: 'Tell us more about the event...',
filled: true,
fillColor: theme.cardColor,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(_cornerRadius - 8), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
),
const SizedBox(height: 18),
// Event Images header
Text('Event Images', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
// Cover image
Text('Cover Image', style: theme.textTheme.bodySmall),
const SizedBox(height: 8),
GestureDetector(
onTap: _pickCoverImage,
child: _imagePickerPlaceholder(file: _coverImageFile, label: 'Cover Image'),
),
const SizedBox(height: 12),
// Thumbnail image
Text('Thumbnail', style: theme.textTheme.bodySmall),
const SizedBox(height: 8),
GestureDetector(
onTap: _pickThumbnailImage,
child: _imagePickerPlaceholder(file: _thumbImageFile, label: 'Thumbnail'),
),
const SizedBox(height: 22),
// Submit button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_cornerRadius - 6)),
),
child: _submitting
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.2))
: const Text('Submit for Verification', style: TextStyle(fontWeight: FontWeight.w600)),
),
),
],
),
);
}
Widget _roundedTextField({required TextEditingController controller, required String hint}) {
final theme = Theme.of(context);
return TextField(
controller: controller,
decoration: InputDecoration(
hintText: hint,
filled: true,
fillColor: theme.cardColor,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(_cornerRadius - 8), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
);
}
Widget _imagePickerPlaceholder({XFile? file, required String label}) {
final theme = Theme.of(context);
if (file == null) {
return Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image, size: 28, color: theme.hintColor),
const SizedBox(height: 8),
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
],
),
),
);
}
// show picked image (file or network depending on platform)
if (kIsWeb || file.path.startsWith('http')) {
return ClipRRect(
borderRadius: BorderRadius.circular(_cornerRadius - 8),
child: Image.network(file.path, width: double.infinity, height: 120, fit: BoxFit.cover, errorBuilder: (_, __, ___) {
return Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
child: Center(child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
);
}),
);
} else {
final f = File(file.path);
if (!f.existsSync()) {
return Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
child: Center(child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(_cornerRadius - 8),
child: Image.file(f, width: double.infinity, height: 120, fit: BoxFit.cover, errorBuilder: (_, __, ___) {
return Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
child: Center(child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
);
}),
);
}
}
// ── Leaderboard state ──
int _leaderboardTimeFilter = 0; // 0 = All Time, 1 = This Month
int _leaderboardDistrictFilter = 0; // index into _districts
static const List<String> _districts = [
'Overall Kerala',
'Thiruvananthapuram',
'Kollam',
'Pathanamthitta',
'Alappuzha',
'Kottayam',
'Idukki',
'Ernakulam',
'Thrissur',
'Palakkad',
'Malappuram',
'Kozhikode',
'Wayanad',
'Kannur',
'Kasaragod',
];
// Demo leaderboard data
static const List<Map<String, dynamic>> _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<Map<String, dynamic>> _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<String, dynamic> 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<String, dynamic> 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<double>(
tween: Tween(begin: 0, end: progress),
duration: const Duration(milliseconds: 800),
builder: (_, val, __) => LinearProgressIndicator(
value: val,
minHeight: 5,
valueColor: AlwaysStoppedAnimation<Color>(_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) {
// 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(
bottom: false,
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
_buildHeader(context),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: KeyedSubtree(
key: ValueKey<int>(_activeTab),
child: tabContent,
),
),
const SizedBox(height: 36),
],
),
),
),
);
}
}