Files
Eventify-frontend/lib/screens/contribute_screen.dart
Sicherhaven 50caad21a5 release: bump version to 1.4(p) (versionCode 14)
- Update versionCode 12 → 14, versionName 1.3(p) → 1.4(p)
- Update pubspec.yaml version to 1.4.0+14
- Add CHANGELOG.md with full version history
- Update README.md: version badge + changelog section
- Desktop Contribute Dashboard rebuilt to match web version
  - Contributor Dashboard title, 3-tab nav (Contribute/Leaderboard/Achievements)
  - Two-column submit form, tier milestone progress bar
  - Desktop leaderboard with podium, filters, rank table (green points)
  - Desktop achievements 3-column badge grid
  - Inline Reward Shop with RP balance
- Gamification feature module (EP, RP, leaderboard, achievements, shop)
- Profile screen redesigned to match web app layout with animations
- Home screen bottom sheet date filter chips
- Updated API endpoints, login/event detail screens, theme colors
- Added Gilroy font suite, responsive layout improvements
2026-03-18 11:10:56 +05:30

2627 lines
109 KiB
Dart

// lib/screens/contribute_screen.dart
// Contributor Module v2 — matches PRD v3 / TechDocs v2 / Web version.
// 4 tabs: Contribute · Leaderboard · Achievements · Shop
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../core/app_decoration.dart';
import '../features/gamification/models/gamification_models.dart';
import '../features/gamification/providers/gamification_provider.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Tier colour map
// ─────────────────────────────────────────────────────────────────────────────
const _tierColors = <ContributorTier, Color>{
ContributorTier.BRONZE: Color(0xFFCD7F32),
ContributorTier.SILVER: Color(0xFFA8A9AD),
ContributorTier.GOLD: Color(0xFFFFD700),
ContributorTier.PLATINUM: Color(0xFFE5E4E2),
ContributorTier.DIAMOND: Color(0xFF67E8F9),
};
// Icon map for achievement badges
const _badgeIcons = <String, IconData>{
'edit': Icons.edit_outlined,
'star': Icons.star_outline,
'emoji_events': Icons.emoji_events_outlined,
'leaderboard': Icons.leaderboard_outlined,
'photo_library': Icons.photo_library_outlined,
'verified': Icons.verified_outlined,
};
// District list for the contribution form
const _districts = [
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
'Other',
];
const _categories = [
'Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community',
'Dance', 'Film', 'Business', 'Health', 'Education', 'Other',
];
// ─────────────────────────────────────────────────────────────────────────────
// ContributeScreen
// ─────────────────────────────────────────────────────────────────────────────
class ContributeScreen extends StatefulWidget {
const ContributeScreen({Key? key}) : super(key: key);
@override
State<ContributeScreen> createState() => _ContributeScreenState();
}
class _ContributeScreenState extends State<ContributeScreen>
with SingleTickerProviderStateMixin {
static const Color _primary = Color(0xFF0B63D6);
static const double _cornerRadius = 18.0;
int _activeTab = 0;
// ── Contribution form state ──────────────────────────────────────────────
final _formKey = GlobalKey<FormState>();
final _titleCtl = TextEditingController();
final _locationCtl = TextEditingController();
final _organizerCtl = TextEditingController();
final _descriptionCtl = TextEditingController();
final _ticketPriceCtl = TextEditingController();
final _contactCtl = TextEditingController();
final _websiteCtl = TextEditingController();
DateTime? _selectedDate;
TimeOfDay? _selectedTime;
String _selectedCategory = _categories.first;
String _selectedDistrict = _districts.first;
List<XFile> _images = [];
bool _submitting = false;
final _picker = ImagePicker();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<GamificationProvider>().loadAll();
});
}
@override
void dispose() {
_titleCtl.dispose();
_locationCtl.dispose();
_organizerCtl.dispose();
_descriptionCtl.dispose();
_ticketPriceCtl.dispose();
_contactCtl.dispose();
_websiteCtl.dispose();
super.dispose();
}
// ─────────────────────────────────────────────────────────────────────────
// Build
// ─────────────────────────────────────────────────────────────────────────
// Desktop sub-nav state: 0=Submit Event, 1=My Events, 2=Reward Shop
int _desktopSubNav = 0;
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 820;
return Consumer<GamificationProvider>(
builder: (context, provider, _) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7FB),
body: isDesktop
? _buildDesktopLayout(context, provider)
: Column(
children: [
_buildHeader(context, provider),
Expanded(child: _buildTabBody(context, provider)),
],
),
);
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// DESKTOP LAYOUT — matches web at mvnew.eventifyplus.com/contribute
// ═══════════════════════════════════════════════════════════════════════════
static const _desktopTabs = ['Contribute', 'Leaderboard', 'Achievements'];
static const _desktopTabIcons = [Icons.edit_note, null, null];
Widget _buildDesktopLayout(BuildContext context, GamificationProvider provider) {
final profile = provider.profile;
final tier = profile?.tier ?? ContributorTier.BRONZE;
final lifetimeEp = profile?.lifetimeEp ?? 0;
final currentEp = profile?.currentEp ?? 0;
final currentRp = profile?.currentRp ?? 0;
// Calculate next tier threshold
const thresholds = [0, 100, 500, 1500, 5000];
final tierIdx = tier.index;
final nextThresh = tierIdx < 4 ? thresholds[tierIdx + 1] : thresholds[4];
final prevThresh = thresholds[tierIdx];
final progress = tierIdx >= 4 ? 1.0 : (lifetimeEp - prevThresh) / (nextThresh - prevThresh);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
const Text('Contributor Dashboard',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: Color(0xFF111827))),
const SizedBox(height: 6),
const Text('Track your impact, earn rewards, and climb the ranks!',
style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
const SizedBox(height: 24),
// ── Desktop Tab bar (3 tabs in blue pill) ──
Container(
decoration: BoxDecoration(
color: _primary,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(5),
child: Row(
children: List.generate(_desktopTabs.length, (i) {
final isActive = _activeTab == i;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _activeTab = i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (i == 0) ...[
Icon(Icons.edit_note, size: 18,
color: isActive ? _primary : Colors.white70),
const SizedBox(width: 6),
],
Text(
_desktopTabs[i],
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isActive ? _primary : Colors.white,
),
),
],
),
),
),
);
}),
),
),
const SizedBox(height: 20),
// ── Contributor Level card ──
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F45CF), Color(0xFF3B82F6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Contributor Level',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('Start earning rewards by contributing!',
style: TextStyle(color: Colors.white70, fontSize: 13)),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(tierLabel(tier),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$lifetimeEp pts',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
if (tierIdx < 4)
Text('Next: ${tierLabel(ContributorTier.values[tierIdx + 1])} (${thresholds[tierIdx + 1]} pts)',
style: const TextStyle(color: Colors.white70, fontSize: 13)),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
minHeight: 8,
backgroundColor: Colors.white.withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
),
),
],
),
),
const SizedBox(height: 20),
// ── Desktop tab body ──
_buildDesktopTabBody(context, provider),
],
),
),
),
);
}
Widget _buildDesktopTabBody(BuildContext context, GamificationProvider provider) {
switch (_activeTab) {
case 0:
return _buildDesktopContributeTab(context, provider);
case 1:
return _buildDesktopLeaderboardTab(context, provider);
case 2:
return _buildDesktopAchievementsTab(context, provider);
default:
return const SizedBox();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DESKTOP — Contribute Tab
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildDesktopContributeTab(BuildContext context, GamificationProvider provider) {
final profile = provider.profile;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Sub-navigation buttons ──
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => setState(() => _desktopSubNav = 1),
icon: const Icon(Icons.list_alt, size: 18),
label: const Text('My Events'),
style: OutlinedButton.styleFrom(
foregroundColor: _desktopSubNav == 1 ? Colors.white : const Color(0xFF374151),
backgroundColor: _desktopSubNav == 1 ? _primary : Colors.white,
side: BorderSide(color: _desktopSubNav == 1 ? _primary : const Color(0xFFD1D5DB)),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => setState(() => _desktopSubNav = 0),
icon: const Icon(Icons.add, size: 18),
label: const Text('Submit Event'),
style: OutlinedButton.styleFrom(
foregroundColor: _desktopSubNav == 0 ? Colors.white : const Color(0xFF374151),
backgroundColor: _desktopSubNav == 0 ? _primary : Colors.white,
side: BorderSide(color: _desktopSubNav == 0 ? _primary : const Color(0xFFD1D5DB)),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () => setState(() => _desktopSubNav = 2),
icon: const Icon(Icons.shopping_bag_outlined, size: 18),
label: const Text('Reward Shop'),
style: ElevatedButton.styleFrom(
foregroundColor: _desktopSubNav == 2 ? Colors.white : Colors.white,
backgroundColor: _desktopSubNav == 2 ? const Color(0xFF1D4ED8) : _primary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
const SizedBox(height: 20),
// ── Sub-nav content ──
if (_desktopSubNav == 0) _buildDesktopSubmitForm(context, provider),
if (_desktopSubNav == 1) _buildDesktopMyEvents(),
if (_desktopSubNav == 2) _buildDesktopRewardShop(provider),
const SizedBox(height: 24),
// ── Tier progress bar with milestones ──
_buildDesktopTierBar(profile),
],
);
}
Widget _buildDesktopSubmitForm(BuildContext context, GamificationProvider provider) {
return Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: Event Title + Category
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Event Title', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 6),
TextFormField(
controller: _titleCtl,
decoration: InputDecoration(
hintText: 'e.g. Local Food Festival',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
),
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
),
],
),
),
const SizedBox(width: 20),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Category', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
value: _selectedCategory,
items: _categories.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
onChanged: (v) => setState(() => _selectedCategory = v!),
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Row 2: Date + Location
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Date', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final d = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (d != null) setState(() => _selectedDate = d);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFD1D5DB)),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedDate != null
? '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}'
: 'dd/mm/yyyy',
style: TextStyle(
color: _selectedDate != null ? const Color(0xFF111827) : const Color(0xFF9CA3AF),
fontSize: 14),
),
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9CA3AF)),
],
),
),
),
],
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Location', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 6),
TextFormField(
controller: _locationCtl,
decoration: InputDecoration(
hintText: 'e.g. City Park, Calicut',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Organizer Name
const Text('Organizer Name', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 6),
TextFormField(
controller: _organizerCtl,
decoration: InputDecoration(
hintText: 'Individual or Organization Name',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
),
),
const SizedBox(height: 20),
// Description
const Text('Description', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 6),
TextFormField(
controller: _descriptionCtl,
maxLines: 4,
decoration: InputDecoration(
hintText: 'Tell us more about the event...',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
),
),
const SizedBox(height: 20),
// Event Images
const Text('Event Images', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildDesktopImageUpload('Cover Image', Icons.image_outlined)),
const SizedBox(width: 20),
Expanded(child: _buildDesktopImageUpload('Thumbnail', Icons.crop_original)),
],
),
const SizedBox(height: 28),
// Submit button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : () => _submitForm(provider),
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
child: _submitting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Submit for Verification'),
),
),
],
),
),
);
}
Widget _buildDesktopImageUpload(String label, IconData icon) {
return GestureDetector(
onTap: () async {
final picked = await _picker.pickImage(source: ImageSource.gallery);
if (picked != null) setState(() => _images.add(picked));
},
child: Container(
height: 150,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFD1D5DB), style: BorderStyle.solid),
borderRadius: BorderRadius.circular(12),
color: const Color(0xFFFAFBFC),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 36, color: const Color(0xFF9CA3AF)),
const SizedBox(height: 8),
Text(label, style: const TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
],
),
),
),
);
}
Widget _buildDesktopMyEvents() {
return Container(
padding: const EdgeInsets.all(40),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Center(
child: Column(
children: [
Icon(Icons.event_note, size: 48, color: const Color(0xFF9CA3AF)),
const SizedBox(height: 12),
const Text('No submitted events yet', style: TextStyle(color: Color(0xFF6B7280), fontSize: 15)),
const SizedBox(height: 4),
const Text('Events you submit will appear here.', style: TextStyle(color: Color(0xFF9CA3AF), fontSize: 13)),
],
),
),
);
}
Widget _buildDesktopRewardShop(GamificationProvider provider) {
final profile = provider.profile;
final currentRp = profile?.currentRp ?? 0;
final items = provider.shopItems;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Row(
children: [
const Icon(Icons.shopping_bag_outlined, color: Color(0xFF0B63D6), size: 24),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Reward Shop', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18)),
SizedBox(height: 2),
Text('Spend your hard-earned RP on exclusive vouchers and perks.',
style: TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFEF3C7),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFF59E0B)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('YOUR BALANCE ', style: TextStyle(color: Color(0xFF92400E), fontSize: 11, fontWeight: FontWeight.w600)),
Text('$currentRp RP', style: const TextStyle(color: Color(0xFFDC2626), fontSize: 16, fontWeight: FontWeight.w800)),
],
),
),
],
),
),
const SizedBox(height: 16),
// Items grid or empty
if (items.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(60),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE5E7EB), style: BorderStyle.solid),
),
child: Column(
children: [
Icon(Icons.shopping_bag_outlined, size: 48, color: const Color(0xFFD1D5DB)),
const SizedBox(height: 12),
const Text('No items available', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: Color(0xFF374151))),
const SizedBox(height: 4),
const Text('Check back soon for new rewards!', style: TextStyle(color: Color(0xFF9CA3AF), fontSize: 13)),
],
),
)
else
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.3,
children: items.map((item) => _buildDesktopShopCard(item, currentRp, provider)).toList(),
),
],
);
}
Widget _buildDesktopShopCard(ShopItem item, int currentRp, GamificationProvider provider) {
final canRedeem = currentRp >= item.rpCost && item.stockQuantity > 0;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
const SizedBox(height: 4),
Text(item.description, style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${item.rpCost} RP', style: const TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.w700)),
ElevatedButton(
onPressed: canRedeem ? () async {
final code = await provider.redeemItem(item.id);
if (mounted) {
showDialog(context: context, builder: (_) => AlertDialog(
title: const Text('Redeemed!'),
content: Text('Voucher code: $code'),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
));
}
} : null,
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Redeem'),
),
],
),
],
),
);
}
Widget _buildDesktopTierBar(UserGamificationProfile? profile) {
final lifetimeEp = profile?.lifetimeEp ?? 0;
final currentEp = profile?.currentEp ?? 0;
final currentRp = profile?.currentRp ?? 0;
final tier = profile?.tier ?? ContributorTier.BRONZE;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
children: [
// Top row: tier chip + stats + share
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: _primary,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.diamond_outlined, size: 16, color: Colors.white),
const SizedBox(width: 6),
Text(tierLabel(tier).toUpperCase(),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12, letterSpacing: 0.5)),
],
),
),
const SizedBox(width: 16),
Icon(Icons.bolt, size: 18, color: const Color(0xFF6B7280)),
const SizedBox(width: 4),
Text('$currentEp', style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
const Text(' Liquid EP', style: TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
const SizedBox(width: 20),
Icon(Icons.card_giftcard, size: 18, color: const Color(0xFF6B7280)),
const SizedBox(width: 4),
Text('$currentRp', style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
const Text(' RP', style: TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
const Spacer(),
OutlinedButton.icon(
onPressed: () {
Share.share(
'I\'m a ${tierLabel(tier)} contributor on @EventifyPlus with $lifetimeEp EP! 🏆 '
'Discover & contribute to events near you at eventifyplus.com',
subject: 'My Eventify.Plus Contributor Rank',
);
},
icon: const Icon(Icons.share_outlined, size: 16),
label: const Text('Share Rank'),
style: OutlinedButton.styleFrom(
foregroundColor: _primary,
side: const BorderSide(color: Color(0xFFD1D5DB)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
),
],
),
const SizedBox(height: 16),
// Tier milestones
Row(
children: [
_tierMilestone('Bronze', '0 EP', tier.index >= 0),
Expanded(child: Container(height: 2, color: tier.index >= 1 ? _primary : const Color(0xFFE5E7EB))),
_tierMilestone('Silver', '100 EP', tier.index >= 1),
Expanded(child: Container(height: 2, color: tier.index >= 2 ? _primary : const Color(0xFFE5E7EB))),
_tierMilestone('Gold', '500 EP', tier.index >= 2),
Expanded(child: Container(height: 2, color: tier.index >= 3 ? _primary : const Color(0xFFE5E7EB))),
_tierMilestone('Platinum', '1.5K EP', tier.index >= 3),
Expanded(child: Container(height: 2, color: tier.index >= 4 ? _primary : const Color(0xFFE5E7EB))),
_tierMilestone('Diamond', '5K EP', tier.index >= 4),
],
),
],
),
);
}
Widget _tierMilestone(String label, String ep, bool active) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(
fontSize: 12, fontWeight: active ? FontWeight.w700 : FontWeight.w500,
color: active ? const Color(0xFF111827) : const Color(0xFF9CA3AF))),
Text(ep, style: TextStyle(
fontSize: 10, color: active ? const Color(0xFF6B7280) : const Color(0xFFD1D5DB))),
],
);
}
// ═══════════════════════════════════════════════════════════════════════════
// DESKTOP — Leaderboard Tab
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildDesktopLeaderboardTab(BuildContext context, GamificationProvider provider) {
if (provider.isLoading && provider.leaderboard.isEmpty) {
return const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator()));
}
final entries = provider.leaderboard;
final matching = entries.where((e) => e.isCurrentUser).toList();
final myEntry = matching.isNotEmpty ? matching.first : null;
return Column(
children: [
// Filters
_buildDesktopLeaderboardFilters(provider),
const SizedBox(height: 16),
// Podium
if (entries.length >= 3) _buildDesktopPodium(entries.take(3).toList()),
const SizedBox(height: 16),
// Rank table
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
// Headers
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))),
),
child: Row(
children: const [
SizedBox(width: 50, child: Text('RANK', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
SizedBox(width: 50),
Expanded(child: Text('USER', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
SizedBox(width: 100, child: Text('POINTS', textAlign: TextAlign.right, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
SizedBox(width: 20),
SizedBox(width: 80, child: Text('LEVEL', textAlign: TextAlign.center, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
SizedBox(width: 20),
SizedBox(width: 100, child: Text('EVENTS ADDED', textAlign: TextAlign.right, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
],
),
),
// Rows
...entries.skip(3).map((e) => _buildDesktopRankRow(e)).toList(),
],
),
),
// My rank
if (myEntry != null) ...[
const SizedBox(height: 16),
_buildMyRankCard(myEntry),
],
],
);
}
Widget _buildDesktopLeaderboardFilters(GamificationProvider provider) {
return Column(
children: [
// Time toggle — right aligned
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_timePill('All Time', 'all_time', provider),
const SizedBox(width: 6),
_timePill('This Month', 'this_month', provider),
],
),
const SizedBox(height: 10),
// District pills
SizedBox(
height: 42,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _lbDistricts.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final d = _lbDistricts[i];
final isActive = provider.leaderboardDistrict == d;
return GestureDetector(
onTap: () => provider.setDistrict(d),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
decoration: BoxDecoration(
color: isActive ? _primary : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isActive ? _primary : const Color(0xFFD1D5DB)),
),
child: Text(d, style: TextStyle(
color: isActive ? Colors.white : const Color(0xFF374151),
fontWeight: FontWeight.w500, fontSize: 13,
)),
),
);
},
),
),
],
);
}
// Reuses _lbDistricts defined in the mobile leaderboard section below
Widget _buildDesktopPodium(List<LeaderboardEntry> top3) {
final first = top3[0];
final second = top3[1];
final third = top3[2];
return SizedBox(
height: 260,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// #2 Silver
_desktopPodiumUser(second, 2, 120, const Color(0xFFBDBDBD)),
const SizedBox(width: 8),
// #1 Gold
_desktopPodiumUser(first, 1, 160, const Color(0xFFF59E0B)),
const SizedBox(width: 8),
// #3 Bronze
_desktopPodiumUser(third, 3, 100, const Color(0xFF92400E)),
],
),
);
}
Widget _desktopPodiumUser(LeaderboardEntry entry, int rank, double pillarHeight, Color pillarColor) {
final rankColors = {1: const Color(0xFFF59E0B), 2: const Color(0xFF6B7280), 3: const Color(0xFF92400E)};
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Avatar with rank badge
Stack(
clipBehavior: Clip.none,
children: [
CircleAvatar(
radius: rank == 1 ? 36 : 28,
backgroundColor: pillarColor.withOpacity(0.2),
child: Text(entry.username[0], style: TextStyle(fontSize: rank == 1 ? 24 : 18, fontWeight: FontWeight.w700, color: pillarColor)),
),
Positioned(
bottom: -4, right: -4,
child: Container(
width: 22, height: 22,
decoration: BoxDecoration(
color: rankColors[rank],
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Center(child: Text('$rank', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700))),
),
),
],
),
const SizedBox(height: 8),
Text(entry.username, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
Text(_fmtPts(entry.lifetimeEp),
style: TextStyle(color: rankColors[rank], fontWeight: FontWeight.w600, fontSize: 12)),
const SizedBox(height: 6),
// Pillar
Container(
width: 140,
height: pillarHeight,
decoration: BoxDecoration(
color: pillarColor.withOpacity(rank == 1 ? 0.85 : 0.6),
borderRadius: const BorderRadius.only(topLeft: Radius.circular(8), topRight: Radius.circular(8)),
),
),
],
);
}
Widget _buildDesktopRankRow(LeaderboardEntry entry) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: entry.isCurrentUser ? const Color(0xFFEFF6FF) : Colors.white,
border: const Border(bottom: BorderSide(color: Color(0xFFF3F4F6))),
),
child: Row(
children: [
SizedBox(width: 50, child: Text('${entry.rank}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: Color(0xFF374151)))),
CircleAvatar(
radius: 18,
backgroundColor: const Color(0xFFE5E7EB),
child: Text(entry.username[0], style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
const SizedBox(width: 12),
Expanded(child: Text(entry.username, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14))),
SizedBox(
width: 100,
child: Text(_fmtPts(entry.lifetimeEp),
textAlign: TextAlign.right,
style: const TextStyle(color: Color(0xFF16A34A), fontWeight: FontWeight.w700, fontSize: 14)),
),
const SizedBox(width: 20),
SizedBox(
width: 80,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(tierLabel(entry.tier),
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)),
),
),
),
const SizedBox(width: 20),
SizedBox(
width: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Icon(Icons.calendar_today, size: 14, color: Color(0xFF9CA3AF)),
const SizedBox(width: 6),
Text('${entry.eventsCount}', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
],
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// DESKTOP — Achievements Tab
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildDesktopAchievementsTab(BuildContext context, GamificationProvider provider) {
final badges = provider.achievements;
if (provider.isLoading && badges.isEmpty) {
return const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator()));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Your Badges', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 20, color: Color(0xFF111827))),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.6,
children: badges.map((badge) => _buildDesktopBadgeCard(badge)).toList(),
),
],
);
}
Widget _buildDesktopBadgeCard(AchievementBadge badge) {
final icon = _badgeIcons[badge.iconName] ?? Icons.emoji_events_outlined;
final isUnlocked = badge.isUnlocked;
// Badge icon colors
final iconColors = <String, Color>{
'edit': const Color(0xFF3B82F6),
'star': const Color(0xFFF59E0B),
'emoji_events': const Color(0xFFF97316),
'leaderboard': const Color(0xFF8B5CF6),
'photo_library': const Color(0xFF6B7280),
'verified': const Color(0xFF10B981),
};
final bgColors = <String, Color>{
'edit': const Color(0xFFDBEAFE),
'star': const Color(0xFFFEF3C7),
'emoji_events': const Color(0xFFFED7AA),
'leaderboard': const Color(0xFFEDE9FE),
'photo_library': const Color(0xFFF3F4F6),
'verified': const Color(0xFFD1FAE5),
};
final iconColor = isUnlocked ? (iconColors[badge.iconName] ?? const Color(0xFF6B7280)) : const Color(0xFF9CA3AF);
final bgColor = isUnlocked ? (bgColors[badge.iconName] ?? const Color(0xFFF3F4F6)) : const Color(0xFFF3F4F6);
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 42, height: 42,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 22),
),
if (!isUnlocked) ...[
const Spacer(),
const Icon(Icons.lock_outline, size: 16, color: Color(0xFF9CA3AF)),
],
],
),
const SizedBox(height: 12),
Text(badge.title, style: TextStyle(
fontWeight: FontWeight.w700, fontSize: 14,
color: isUnlocked ? const Color(0xFF111827) : const Color(0xFF9CA3AF))),
const SizedBox(height: 2),
Text(badge.description, style: TextStyle(
fontSize: 12, color: isUnlocked ? const Color(0xFF6B7280) : const Color(0xFFD1D5DB)),
maxLines: 2, overflow: TextOverflow.ellipsis),
if (badge.progress < 1.0 && badge.progress > 0) ...[
const Spacer(),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: badge.progress,
minHeight: 6,
backgroundColor: const Color(0xFFE5E7EB),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
),
),
),
const SizedBox(width: 8),
Text('${(badge.progress * 100).round()}%',
style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280), fontWeight: FontWeight.w600)),
],
),
],
],
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// MOBILE Header (unchanged)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildHeader(BuildContext context, GamificationProvider provider) {
final theme = Theme.of(context);
final profile = provider.profile;
final tier = profile?.tier ?? ContributorTier.BRONZE;
final tierColor = _tierColors[tier]!;
final currentEp = profile?.currentEp ?? 0;
final currentRp = profile?.currentRp ?? 0;
final lifetimeEp = profile?.lifetimeEp ?? 0;
final nextThresh = nextTierThreshold(tier);
final startEp = tierStartEp(tier);
final progress = nextThresh == null
? 1.0
: ((lifetimeEp - startEp) / (nextThresh - startEp)).clamp(0.0, 1.0);
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 32, 20, 20),
decoration: AppDecoration.blueGradient.copyWith(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(_cornerRadius),
bottomRight: Radius.circular(_cornerRadius),
),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: SafeArea(
bottom: false,
child: Column(
children: [
const SizedBox(height: 4),
Text(
'Contributor Dashboard',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white, fontWeight: FontWeight.w700, fontSize: 20,
),
),
const SizedBox(height: 6),
Text(
'Track your impact, earn rewards, and climb the ranks!',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withOpacity(0.88), fontSize: 12,
),
),
const SizedBox(height: 16),
// ── Segmented tabs ──────────────────────────────────────────────
_buildSegmentedTabs(context),
const SizedBox(height: 14),
// ── Contributor level card ──────────────────────────────────────
if (provider.isLoading && profile == null)
const SizedBox(
height: 80,
child: Center(child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.10),
borderRadius: BorderRadius.circular(_cornerRadius - 2),
border: Border.all(color: Colors.white.withOpacity(0.12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Contributor Level',
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white, fontWeight: FontWeight.w700,
),
),
),
// Tier badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: tierColor.withOpacity(0.85),
borderRadius: BorderRadius.circular(20),
),
child: Text(
tierLabel(tier),
style: const TextStyle(
color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 10),
// EP / RP stats row
Row(
children: [
_statChip('$currentEp EP', 'This month', Colors.white.withOpacity(0.15)),
const SizedBox(width: 8),
_statChip('$currentRp RP', 'Redeemable', Colors.white.withOpacity(0.15)),
const SizedBox(width: 8),
_statChip('$lifetimeEp', 'Lifetime EP', Colors.white.withOpacity(0.15)),
],
),
const SizedBox(height: 10),
// Progress bar
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: progress),
duration: const Duration(milliseconds: 800),
builder: (_, val, __) => ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: val,
minHeight: 6,
valueColor: AlwaysStoppedAnimation<Color>(tierColor),
backgroundColor: Colors.white24,
),
),
),
const SizedBox(height: 6),
Text(
nextThresh != null
? '${nextThresh - lifetimeEp} EP to ${tierLabel(ContributorTier.values[tier.index + 1])}'
: '🎉 Maximum tier reached!',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11),
),
],
),
),
],
),
),
);
}
Widget _statChip(String value, String label, Color bg) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
child: Column(
children: [
Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
Text(label, style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 10)),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Segmented Tabs (4 tabs)
// ─────────────────────────────────────────────────────────────────────────
static const Curve _bouncyCurve = Cubic(0.37, 1.95, 0.66, 0.56);
static const List<IconData> _tabIcons = [
Icons.edit_outlined,
Icons.emoji_events_outlined,
Icons.workspace_premium_outlined,
Icons.storefront_outlined,
];
Widget _buildSegmentedTabs(BuildContext context) {
const tabs = ['Contribute', 'Leaderboard', 'Achievements', 'Shop'];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: LayoutBuilder(
builder: (context, constraints) {
const double padding = 5.0;
final tabWidth = (constraints.maxWidth - padding * 2) / tabs.length;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
height: 52,
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: 450),
curve: _bouncyCurve,
left: padding + _activeTab * tabWidth,
top: padding,
width: tabWidth,
height: 52 - padding * 2,
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.10), blurRadius: 12, offset: const Offset(0, 3)),
],
),
),
),
// Labels
Padding(
padding: const EdgeInsets.all(padding),
child: Row(
children: List.generate(tabs.length, (i) {
final isActive = _activeTab == i;
return GestureDetector(
onTap: () => setState(() => _activeTab = i),
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: tabWidth,
height: 52 - padding * 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_tabIcons[i],
size: 15,
color: isActive ? _primary : Colors.white.withOpacity(0.8),
),
const SizedBox(height: 2),
Text(
tabs[i],
style: TextStyle(
fontSize: 10,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: isActive ? _primary : Colors.white.withOpacity(0.85),
),
),
],
),
),
);
}),
),
),
],
),
),
);
},
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Tab Body Router
// ─────────────────────────────────────────────────────────────────────────
Widget _buildTabBody(BuildContext context, GamificationProvider provider) {
switch (_activeTab) {
case 0: return _buildContributeTab(context, provider);
case 1: return _buildLeaderboardTab(context, provider);
case 2: return _buildAchievementsTab(context, provider);
case 3: return _buildShopTab(context, provider);
default: return const SizedBox();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 0 — CONTRIBUTE
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildContributeTab(BuildContext context, GamificationProvider provider) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Submit an Event', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(
'Fill in the details below. Earn up to 10 EP per approved submission.',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 20),
_formCard([
_formField(_titleCtl, 'Event Name *', Icons.event, required: true),
_divider(),
_categoryDropdown(),
_divider(),
_districtDropdown(),
]),
const SizedBox(height: 12),
_formCard([
_dateTile(),
_divider(),
_timeTile(),
]),
const SizedBox(height: 12),
_formCard([
_formField(_locationCtl, 'Location / Venue', Icons.location_on_outlined),
_divider(),
_formField(_organizerCtl, 'Organizer Name', Icons.person_outline),
_divider(),
_formField(_descriptionCtl, 'Description', Icons.notes_outlined, maxLines: 3),
]),
const SizedBox(height: 12),
_formCard([
_formField(
_ticketPriceCtl, 'Ticket Price (₹)', Icons.confirmation_number_outlined,
keyboardType: TextInputType.number,
hint: 'Leave blank if free',
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
_divider(),
_formField(_contactCtl, 'Contact Details', Icons.phone_outlined),
_divider(),
_formField(_websiteCtl, 'Website / Social Media', Icons.link_outlined),
]),
const SizedBox(height: 12),
// Image picker
_buildImagePickerSection(theme),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 0,
),
onPressed: _submitting ? null : () => _submitForm(provider),
child: _submitting
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Submit for Verification', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)),
),
),
const SizedBox(height: 12),
Center(
child: Text(
'Your submission will be reviewed by our team.\nApproved events earn EP immediately.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[500], fontSize: 11),
),
),
],
),
),
);
}
Widget _formCard(List<Widget> children) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(children: children),
);
}
Widget _divider() => const Divider(height: 1, indent: 16, endIndent: 16, color: Color(0xFFF0F0F0));
Widget _formField(
TextEditingController ctl,
String label,
IconData icon, {
bool required = false,
int maxLines = 1,
TextInputType keyboardType = TextInputType.text,
String? hint,
List<TextInputFormatter>? inputFormatters,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TextFormField(
controller: ctl,
maxLines: maxLines,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon, size: 18, color: Colors.grey[500]),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
labelStyle: TextStyle(color: Colors.grey[600], fontSize: 13),
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 13),
),
validator: required ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null : null,
),
);
}
Widget _categoryDropdown() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: DropdownButtonFormField<String>(
value: _selectedCategory,
decoration: InputDecoration(
labelText: 'Category *',
prefixIcon: Icon(Icons.category_outlined, size: 18, color: Colors.grey[500]),
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
labelStyle: TextStyle(color: Colors.grey[600], fontSize: 13),
),
items: _categories.map((c) => DropdownMenuItem(value: c, child: Text(c, style: const TextStyle(fontSize: 14)))).toList(),
onChanged: (v) => setState(() => _selectedCategory = v!),
isExpanded: true,
icon: Icon(Icons.keyboard_arrow_down, color: Colors.grey[500]),
),
);
}
Widget _districtDropdown() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: DropdownButtonFormField<String>(
value: _selectedDistrict,
decoration: InputDecoration(
labelText: 'District *',
prefixIcon: Icon(Icons.map_outlined, size: 18, color: Colors.grey[500]),
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
labelStyle: TextStyle(color: Colors.grey[600], fontSize: 13),
),
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d, style: const TextStyle(fontSize: 14)))).toList(),
onChanged: (v) => setState(() => _selectedDistrict = v!),
isExpanded: true,
icon: Icon(Icons.keyboard_arrow_down, color: Colors.grey[500]),
),
);
}
Widget _dateTile() {
return ListTile(
leading: Icon(Icons.calendar_today_outlined, size: 18, color: Colors.grey[500]),
title: Text(
_selectedDate != null
? '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}'
: 'Select Date *',
style: TextStyle(fontSize: 14, color: _selectedDate != null ? Colors.black87 : Colors.grey[600]),
),
onTap: _pickDate,
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
);
}
Widget _timeTile() {
return ListTile(
leading: Icon(Icons.access_time_outlined, size: 18, color: Colors.grey[500]),
title: Text(
_selectedTime != null ? _selectedTime!.format(context) : 'Select Time',
style: TextStyle(fontSize: 14, color: _selectedTime != null ? Colors.black87 : Colors.grey[600]),
),
onTap: _pickTime,
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
);
}
Widget _buildImagePickerSection(ThemeData theme) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.photo_library_outlined, size: 18, color: Colors.grey[500]),
const SizedBox(width: 8),
Text('Images (up to 5)', style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey[700])),
const Spacer(),
Text('${_images.length}/5', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
],
),
const SizedBox(height: 12),
if (_images.isNotEmpty)
SizedBox(
height: 80,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _images.length + (_images.length < 5 ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
if (i == _images.length) return _addImageButton();
final img = _images[i];
return Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: kIsWeb
? const SizedBox(width: 80, height: 80, child: Icon(Icons.image))
: Image.file(File(img.path), width: 80, height: 80, fit: BoxFit.cover),
),
Positioned(
top: 2, right: 2,
child: GestureDetector(
onTap: () => setState(() => _images.removeAt(i)),
child: Container(
decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle),
padding: const EdgeInsets.all(2),
child: const Icon(Icons.close, size: 12, color: Colors.white),
),
),
),
],
);
},
),
)
else
_addImageButton(full: true),
],
),
);
}
Widget _addImageButton({bool full = false}) {
return GestureDetector(
onTap: _pickImages,
child: Container(
width: full ? double.infinity : 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFF5F7FB),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFD1D5DB), style: BorderStyle.solid),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined, color: Colors.grey[400], size: full ? 28 : 22),
if (full) ...[
const SizedBox(height: 4),
Text('Add Photos', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
],
],
),
),
);
}
Future<void> _pickDate() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? now,
firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 3),
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(colorScheme: ColorScheme.light(primary: _primary)),
child: child!,
),
);
if (picked != null) setState(() => _selectedDate = picked);
}
Future<void> _pickTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _selectedTime ?? TimeOfDay.now(),
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(colorScheme: ColorScheme.light(primary: _primary)),
child: child!,
),
);
if (picked != null) setState(() => _selectedTime = picked);
}
Future<void> _pickImages() async {
try {
final List<XFile> picked = await _picker.pickMultiImage(imageQuality: 80);
if (picked.isNotEmpty) {
setState(() {
_images = [..._images, ...picked].take(5).toList();
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick images: $e')));
}
}
}
Future<void> _submitForm(GamificationProvider provider) async {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (_selectedDate == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a date')));
return;
}
setState(() => _submitting = true);
try {
await provider.submitContribution({
'title': _titleCtl.text.trim(),
'category': _selectedCategory,
'district': _selectedDistrict,
'date': _selectedDate!.toIso8601String(),
'time': _selectedTime?.format(context),
'location': _locationCtl.text.trim(),
'organizer_name': _organizerCtl.text.trim(),
'description': _descriptionCtl.text.trim(),
'ticket_price': _ticketPriceCtl.text.trim(),
'contact': _contactCtl.text.trim(),
'website': _websiteCtl.text.trim(),
'images': _images.map((f) => f.path).toList(),
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('✅ Submitted for verification! You\'ll earn EP once approved.'),
backgroundColor: Color(0xFF16A34A),
),
);
_clearForm();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red));
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _clearForm() {
_titleCtl.clear();
_locationCtl.clear();
_organizerCtl.clear();
_descriptionCtl.clear();
_ticketPriceCtl.clear();
_contactCtl.clear();
_websiteCtl.clear();
setState(() {
_selectedDate = null;
_selectedTime = null;
_selectedCategory = _categories.first;
_selectedDistrict = _districts.first;
_images = [];
});
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 1 — LEADERBOARD (matches web version at mvnew.eventifyplus.com/contribute)
// ═══════════════════════════════════════════════════════════════════════════
// Kerala districts matching the web version (leaderboard filter)
static const _lbDistricts = [
'Overall Kerala',
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
// Format a points number with comma separator + " pts" suffix
static String _fmtPts(int ep) {
if (ep >= 1000) {
final s = ep.toString();
final intPart = s.substring(0, s.length - 3);
final fracPart = s.substring(s.length - 3);
return '$intPart,$fracPart pts';
}
return '$ep pts';
}
Widget _buildLeaderboardTab(BuildContext context, GamificationProvider provider) {
if (provider.isLoading && provider.leaderboard.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final entries = provider.leaderboard;
final _matching = entries.where((e) => e.isCurrentUser).toList();
final myEntry = _matching.isNotEmpty ? _matching.first : null;
return Column(
children: [
// ── Time period toggle (top-right) + district scroll ──────────────────
_buildLeaderboardFilters(provider),
Expanded(
child: Container(
color: const Color(0xFFFAFBFC),
child: CustomScrollView(
slivers: [
// Podium top-3
if (entries.length >= 3)
SliverToBoxAdapter(child: _buildPodium(entries.take(3).toList())),
// Column headers
SliverToBoxAdapter(
child: Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
const SizedBox(width: 36, child: Text('RANK', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
const SizedBox(width: 44),
const Expanded(child: Text('USER', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
const SizedBox(width: 72, child: Text('POINTS', textAlign: TextAlign.right, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
const SizedBox(width: 8),
const SizedBox(width: 60, child: Text('LEVEL', textAlign: TextAlign.center, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
const SizedBox(width: 8),
const SizedBox(width: 36, child: Text('EVENTS', textAlign: TextAlign.right, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
],
),
),
),
// Ranked list (rank 4+)
SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) {
final entry = entries.length > 3 ? entries[i + 3] : entries[i];
return _buildRankRow(entry);
},
childCount: entries.length > 3 ? entries.length - 3 : 0,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
),
),
),
// My rank sticky card with Share
if (myEntry != null) SafeArea(top: false, child: _buildMyRankCard(myEntry)),
],
);
}
Widget _buildLeaderboardFilters(GamificationProvider provider) {
return Container(
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Time period toggle — right-aligned
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_timePill('All Time', 'all_time', provider),
const SizedBox(width: 6),
_timePill('This Month', 'this_month', provider),
],
),
),
// Horizontal scroll of district pills
SizedBox(
height: 42,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
itemCount: _lbDistricts.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final d = _lbDistricts[i];
final isActive = provider.leaderboardDistrict == d;
return GestureDetector(
onTap: () => provider.setDistrict(d),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: isActive ? _primary : Colors.white,
border: Border.all(color: isActive ? _primary : const Color(0xFFD1D5DB)),
borderRadius: BorderRadius.circular(999),
),
child: Text(
d,
style: TextStyle(
color: isActive ? Colors.white : const Color(0xFF374151),
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
fontSize: 13,
),
),
),
);
},
),
),
const Divider(height: 1, color: Color(0xFFE5E7EB)),
],
),
);
}
Widget _timePill(String label, String key, GamificationProvider provider) {
final isActive = provider.leaderboardTimePeriod == key;
return GestureDetector(
onTap: () => provider.setTimePeriod(key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: isActive ? _primary : const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(
color: isActive ? Colors.white : const Color(0xFF6B7280),
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
fontSize: 12,
),
),
),
);
}
Widget _buildPodium(List<LeaderboardEntry> top3) {
// Layout: [#2 left] [#1 centre, tallest] [#3 right]
final order = [top3[1], top3[0], top3[2]];
final heights = [90.0, 120.0, 70.0];
// Pillar colors matching the web: silver, gold/yellow, brown
final pillarColors = [
const Color(0xFFBDBDBD), // 2nd: silver-grey
const Color(0xFFF59E0B), // 1st: gold/amber
const Color(0xFF92400E), // 3rd: bronze-brown
];
// Badge colors (overlaid on avatar)
final badgeColors = [
const Color(0xFFD97706), // #2: orange
const Color(0xFF1D4ED8), // #1: blue
const Color(0xFF92400E), // #3: brown
];
final ranks = [2, 1, 3];
return Container(
color: const Color(0xFFFAFBFC),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(3, (i) {
final e = order[i];
final avatarSize = i == 1 ? 64.0 : 52.0; // #1 is larger
return Expanded(
child: Column(
children: [
// Avatar with rank badge overlaid
Stack(
clipBehavior: Clip.none,
children: [
// Avatar circle
Container(
width: avatarSize,
height: avatarSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE0F2FE),
border: Border.all(color: pillarColors[i], width: 2.5),
),
child: Center(
child: Text(
e.username.isNotEmpty ? e.username[0].toUpperCase() : '?',
style: TextStyle(
fontSize: i == 1 ? 24 : 18,
fontWeight: FontWeight.w800,
color: pillarColors[i],
),
),
),
),
// Rank badge — bottom-right corner of avatar
Positioned(
bottom: -2,
right: -2,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: badgeColors[i],
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
child: Center(
child: Text(
'${ranks[i]}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w800),
),
),
),
),
],
),
const SizedBox(height: 8),
Text(
e.username.split(' ').first,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Color(0xFF111827)),
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
Text(
_fmtPts(e.lifetimeEp),
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF0F45CF)),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Podium pillar
Container(
height: heights[i],
decoration: BoxDecoration(
color: pillarColors[i],
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
),
],
),
);
}),
),
);
}
Widget _buildRankRow(LeaderboardEntry entry) {
final tierColor = _tierColors[entry.tier]!;
final isMe = entry.isCurrentUser;
return Container(
decoration: BoxDecoration(
color: isMe ? const Color(0xFFEFF6FF) : Colors.white,
border: const Border(bottom: BorderSide(color: Color(0xFFE5E7EB), width: 0.8)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Rank number
SizedBox(
width: 36,
child: Text(
'${entry.rank}',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
color: isMe ? _primary : const Color(0xFF111827),
),
),
),
// Avatar circle
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE0F2FE),
border: Border.all(color: tierColor.withOpacity(0.5), width: 1.5),
),
child: Center(
child: Text(
entry.username.isNotEmpty ? entry.username[0].toUpperCase() : '?',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: tierColor),
),
),
),
const SizedBox(width: 8),
// Name
Expanded(
child: Text(
entry.username + (isMe ? ' (You)' : ''),
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: isMe ? _primary : const Color(0xFF111827),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Points
SizedBox(
width: 80,
child: Text(
_fmtPts(entry.lifetimeEp),
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF0F45CF)),
),
),
const SizedBox(width: 8),
// Level badge
SizedBox(
width: 60,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: tierColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
tierLabel(entry.tier),
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: tierColor),
textAlign: TextAlign.center,
),
),
),
),
const SizedBox(width: 8),
// Events added
SizedBox(
width: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Icon(Icons.calendar_today_outlined, size: 12, color: Color(0xFF9CA3AF)),
const SizedBox(width: 3),
Text('${entry.eventsCount}', style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280), fontWeight: FontWeight.w500)),
],
),
),
],
),
);
}
Widget _buildMyRankCard(LeaderboardEntry me) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 10, offset: const Offset(0, -4))],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(color: _primary, borderRadius: BorderRadius.circular(10)),
child: Text('Your Rank: #${me.rank}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
),
const SizedBox(width: 10),
Expanded(
child: Text('${_fmtPts(me.lifetimeEp)} · ${tierLabel(me.tier)}',
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12)),
),
GestureDetector(
onTap: () {
Share.share(
'I\'m ranked #${me.rank} on @EventifyPlus with ${me.lifetimeEp} EP! 🏆 '
'Discover & contribute to events near you at eventifyplus.com',
subject: 'My Eventify.Plus Leaderboard Rank',
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(border: Border.all(color: _primary), borderRadius: BorderRadius.circular(10)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.share_outlined, size: 14, color: _primary),
const SizedBox(width: 4),
Text('Share', style: TextStyle(color: _primary, fontSize: 12, fontWeight: FontWeight.w600)),
],
),
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 2 — ACHIEVEMENTS
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildAchievementsTab(BuildContext context, GamificationProvider provider) {
final theme = Theme.of(context);
if (provider.isLoading && provider.achievements.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final badges = provider.achievements;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Your Badges', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(
'Earn badges by contributing events and climbing tiers.',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
),
const SizedBox(height: 16),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: badges.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.95,
),
itemBuilder: (_, i) => _buildBadgeCard(badges[i], theme),
),
],
),
);
}
Widget _buildBadgeCard(AchievementBadge badge, ThemeData theme) {
final icon = _badgeIcons[badge.iconName] ?? Icons.star_outline;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
border: badge.isUnlocked ? Border.all(color: _primary.withOpacity(0.3)) : null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: badge.isUnlocked ? _primary.withOpacity(0.12) : Colors.grey[100],
),
child: Icon(
badge.isUnlocked ? icon : Icons.lock_outline,
size: 26,
color: badge.isUnlocked ? _primary : Colors.grey[400],
),
),
const SizedBox(height: 10),
Text(
badge.title,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: badge.isUnlocked ? Colors.black87 : Colors.grey[500],
),
),
const SizedBox(height: 4),
Text(
badge.description,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10, color: Colors.grey[500]),
),
if (!badge.isUnlocked && badge.progress > 0) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: badge.progress,
minHeight: 4,
valueColor: AlwaysStoppedAnimation<Color>(_primary.withOpacity(0.6)),
backgroundColor: Colors.grey[200]!,
),
),
const SizedBox(height: 3),
Text('${(badge.progress * 100).round()}%', style: TextStyle(fontSize: 9, color: Colors.grey[500])),
],
if (badge.isUnlocked)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: const Color(0xFFDCFCE7), borderRadius: BorderRadius.circular(8)),
child: const Text('Unlocked', style: TextStyle(fontSize: 9, color: Color(0xFF16A34A), fontWeight: FontWeight.w600)),
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 3 — SHOP
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildShopTab(BuildContext context, GamificationProvider provider) {
final theme = Theme.of(context);
final rp = provider.profile?.currentRp ?? 0;
if (provider.isLoading && provider.shopItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
// RP balance banner
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF0B63D6), Color(0xFF3B82F6)]),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.monetization_on_outlined, color: Colors.white, size: 16),
const SizedBox(width: 6),
Text('$rp RP Balance', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14)),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'10 EP = 1 RP • Converted monthly',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600], fontSize: 11),
),
),
],
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
itemCount: provider.shopItems.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.78,
),
itemBuilder: (_, i) => _buildShopCard(context, provider, provider.shopItems[i]),
),
),
],
);
}
Widget _buildShopCard(BuildContext context, GamificationProvider provider, ShopItem item) {
final rp = provider.profile?.currentRp ?? 0;
final canRedeem = rp >= item.rpCost && item.stockQuantity > 0;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon area
Container(
height: 60,
decoration: BoxDecoration(
color: _primary.withOpacity(0.07),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Icon(
item.stockQuantity == 0 ? Icons.inventory_2_outlined : Icons.card_giftcard_outlined,
size: 30,
color: item.stockQuantity == 0 ? Colors.grey[400] : _primary,
),
),
),
const SizedBox(height: 10),
Text(item.name, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text(item.description, style: TextStyle(fontSize: 10, color: Colors.grey[500]), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFF59E0B).withOpacity(0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text('${item.rpCost} RP', style: const TextStyle(color: Color(0xFFD97706), fontSize: 11, fontWeight: FontWeight.w700)),
),
const SizedBox(width: 4),
if (item.stockQuantity == 0)
const Text('Out of stock', style: TextStyle(fontSize: 9, color: Colors.redAccent))
else
Text('${item.stockQuantity} left', style: TextStyle(fontSize: 9, color: Colors.grey[500])),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 34,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: canRedeem ? _primary : Colors.grey[200],
foregroundColor: canRedeem ? Colors.white : Colors.grey[400],
elevation: 0,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: canRedeem ? () => _confirmRedeem(context, provider, item) : null,
child: Text(
item.stockQuantity == 0 ? 'Out of Stock' : canRedeem ? 'Redeem' : 'Need ${item.rpCost - rp} more RP',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
),
),
),
],
),
),
);
}
Future<void> _confirmRedeem(BuildContext context, GamificationProvider provider, ShopItem item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Confirm Redemption', style: TextStyle(fontWeight: FontWeight.w700)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text('This will deduct ${item.rpCost} RP from your balance.', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: _primary),
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm', style: TextStyle(color: Colors.white)),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final voucherCode = await provider.redeemItem(item.id);
if (!mounted) return;
await showDialog(
context: context,
builder: (_) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
children: [
Icon(Icons.check_circle, color: Color(0xFF16A34A)),
SizedBox(width: 8),
Text('Redeemed!', style: TextStyle(fontWeight: FontWeight.w700)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Your voucher code for ${item.name}:', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFD1D5DB)),
),
child: Text(
voucherCode,
style: const TextStyle(fontFamily: 'monospace', fontWeight: FontWeight.w700, fontSize: 16, letterSpacing: 2),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
Text('Save this code — it will not be shown again.', style: TextStyle(color: Colors.grey[500], fontSize: 11)),
],
),
actions: [
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: _primary),
onPressed: () {
Clipboard.setData(ClipboardData(text: voucherCode));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Voucher code copied!'), backgroundColor: Color(0xFF16A34A)),
);
},
child: const Text('Copy & Close', style: TextStyle(color: Colors.white)),
),
],
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Redemption failed: $e'), backgroundColor: Colors.red));
}
}
}
}