- 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>
1121 lines
42 KiB
Dart
1121 lines
42 KiB
Dart
// 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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|