Files
Eventify-frontend/lib/screens/contribute_screen.dart
Sicherhaven 42b71beae2 feat: PostHog analytics wiring across all key screens
- Commit untracked posthog_service.dart (fire-and-forget HTTP client,
  EU data residency, already used by auth for identify/reset)
- screen() calls: Home, Contribute, Profile, EventDetail (with event_id)
- capture('event_tapped') on hero carousel card tap (source: hero_carousel)
- capture('book_now_tapped') in _navigateToCheckout (event_id + name)
- capture('review_submitted') in _handleSubmit (event_id + rating)
- Covers all 4 expansion items from security audit finding 8.2
2026-04-04 18:45:19 +05:30

2995 lines
124 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/screens/contribute_screen.dart
// 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 '../core/utils/error_utils.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';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import '../widgets/bouncing_loader.dart';
import '../widgets/glass_card.dart';
import '../widgets/landscape_section_header.dart';
import '../widgets/tier_avatar_ring.dart';
import '../features/share/share_rank_card.dart';
import 'contributor_profile_screen.dart';
import '../core/analytics/posthog_service.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,
// ACH-002: icons for expanded badge set (badges 02, 0611)
'trending_up': Icons.trending_up,
'rocket_launch': Icons.rocket_launch_outlined,
'event_hunter': Icons.search_outlined,
'location_on': Icons.location_on_outlined,
'diamond': Icons.diamond_outlined,
'workspace_premium': Icons.workspace_premium_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();
PostHogService.instance.screen('Contribute');
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) {
return Row(
children: [
Flexible(
flex: 2,
child: RepaintBoundary(
child: Container(
decoration: AppDecoration.blueGradient,
child: _buildContributeLeftPanel(context, provider),
),
),
),
Flexible(
flex: 3,
child: RepaintBoundary(
child: _buildContributeRightPanel(context, provider),
),
),
],
);
}
// ── Landscape left panel: contributor info + vertical nav ───────────────
Widget _buildContributeLeftPanel(BuildContext context, GamificationProvider provider) {
final profile = provider.profile;
final tier = profile?.tier ?? ContributorTier.BRONZE;
final lifetimeEp = profile?.lifetimeEp ?? 0;
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);
final tierColor = _tierColors[tier] ?? Colors.white;
return SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
// Title
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Contributor\nDashboard',
style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w800, height: 1.2),
),
),
const SizedBox(height: 6),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Track your impact & earn rewards',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
),
const SizedBox(height: 24),
// Contributor Level badge
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: tierColor.withOpacity(0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: tierColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tierColor.withOpacity(0.6)),
),
child: Text(tierLabel(tier), style: TextStyle(color: tierColor, fontWeight: FontWeight.w700, fontSize: 12)),
),
const Spacer(),
Text('$lifetimeEp pts', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14)),
]),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: Colors.white24,
valueColor: AlwaysStoppedAnimation<Color>(tierColor),
),
),
if (tierIdx < 4) ...[
const SizedBox(height: 6),
Text(
'Next: ${tierLabel(ContributorTier.values[tierIdx + 1])} at ${thresholds[tierIdx + 1]} pts',
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
],
],
),
),
),
const SizedBox(height: 16),
// GAM-002: 3-card EP stat row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_epStatCard('Lifetime EP', '${profile?.lifetimeEp ?? 0}', Icons.star, const Color(0xFFF59E0B)),
const SizedBox(width: 8),
// GAM-003 + GAM-004: Liquid EP card with cycle countdown and progress
Expanded(
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF3B82F6).withOpacity(0.3)),
),
child: Column(
children: [
const Icon(Icons.bolt, color: Color(0xFF3B82F6), size: 20),
const SizedBox(height: 4),
Text(
'${profile?.currentEp ?? 0}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16),
),
const SizedBox(height: 2),
const Text('Liquid EP', style: TextStyle(color: Colors.white60, fontSize: 10), textAlign: TextAlign.center),
if (provider.currentUserStats?.rewardCycleDays != null) ...[
const SizedBox(height: 4),
Text(
'Converts in ${provider.currentUserStats!.rewardCycleDays}d',
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Builder(
builder: (_) {
final days = provider.currentUserStats?.rewardCycleDays ?? 30;
final elapsed = (30 - days).clamp(0, 30);
final ratio = elapsed / 30;
return LinearProgressIndicator(
value: ratio,
minHeight: 4,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
ratio > 0.7 ? const Color(0xFFFBBF24) : const Color(0xFF3B82F6),
),
);
},
),
),
],
],
),
),
),
const SizedBox(width: 8),
_epStatCard('Reward Pts', '${profile?.currentRp ?? 0}', Icons.redeem, const Color(0xFF10B981)),
],
),
),
const SizedBox(height: 16),
// GAM-005: Tier roadmap
_buildTierRoadmap(lifetimeEp, tier),
const SizedBox(height: 24),
// Vertical tab navigation
...List.generate(_desktopTabs.length, (i) {
final isActive = _activeTab == i;
final icons = [Icons.edit_note, Icons.leaderboard_outlined, Icons.emoji_events_outlined];
return GestureDetector(
onTap: () => setState(() => _activeTab = i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(children: [
Icon(icons[i], size: 20, color: isActive ? _primary : Colors.white70),
const SizedBox(width: 12),
Text(
_desktopTabs[i],
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: isActive ? _primary : Colors.white,
),
),
const Spacer(),
if (isActive) Icon(Icons.chevron_right, size: 18, color: _primary),
]),
),
);
}),
const SizedBox(height: 24),
],
),
),
);
}
// ── Landscape right panel: active tab content ────────────────────────────
Widget _buildContributeRightPanel(BuildContext context, GamificationProvider provider) {
String title;
String subtitle;
switch (_activeTab) {
case 1:
title = 'Leaderboard';
subtitle = 'Top contributors this month';
break;
case 2:
title = 'Achievements';
subtitle = 'Your earned badges';
break;
default:
title = 'Submit Event';
subtitle = 'Share events with the community';
}
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LandscapeSectionHeader(title: title, subtitle: subtitle),
Expanded(
child: RepaintBoundary(
child: _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: BouncingLoader()));
}
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: BouncingLoader()));
}
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),
// ACH-002: colors for expanded badge set
'trending_up': const Color(0xFF0EA5E9),
'rocket_launch': const Color(0xFFEC4899),
'event_hunter': const Color(0xFF64748B),
'location_on': const Color(0xFF22C55E),
'diamond': const Color(0xFF06B6D4),
'workspace_premium': const Color(0xFFE879F9),
};
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),
// ACH-002: backgrounds for expanded badge set
'trending_up': const Color(0xFFE0F2FE),
'rocket_launch': const Color(0xFFFCE7F3),
'event_hunter': const Color(0xFFF1F5F9),
'location_on': const Color(0xFFDCFCE7),
'diamond': const Color(0xFFCFFAFE),
'workspace_premium': const Color(0xFFFAE8FF),
};
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 = Curves.easeInOutCubic;
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 RepaintBoundary(
child: 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: 280),
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),
),
),
],
),
),
);
}),
),
),
],
),
),
)); // RepaintBoundary
},
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// 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);
final bottomInset = MediaQuery.of(context).padding.bottom;
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16, 20, 16, 32 + bottomInset),
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),
),
),
// CTR-001/002: Your Submissions list
if (provider.submissions.isNotEmpty) ...[
const SizedBox(height: 28),
Text('Your Submissions', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
...provider.submissions.map((sub) => _buildSubmissionCard(sub, theme)),
],
],
),
),
);
}
Widget _buildSubmissionCard(SubmissionModel sub, ThemeData theme) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor.withOpacity(0.2)),
),
child: Row(
children: [
// Thumbnail or placeholder
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: sub.images.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(sub.images.first, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon(Icons.event, color: Colors.grey)),
)
: const Icon(Icons.event, color: Colors.grey),
),
const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(sub.eventName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text('${sub.category} · ${sub.district}', style: TextStyle(color: Colors.grey[500], fontSize: 11)),
],
),
),
const SizedBox(width: 8),
// Status chip + EP badge
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
statusChip(sub.status),
if (sub.epAwarded > 0) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFDBEAFE),
borderRadius: BorderRadius.circular(8),
),
child: Text('+${sub.epAwarded} EP', style: const TextStyle(color: Color(0xFF2563EB), fontWeight: FontWeight.w700, fontSize: 11)),
),
],
],
),
],
),
);
}
Widget _formCard(List<Widget> children) {
return RepaintBoundary(
child: 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(userFriendlyError(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(userFriendlyError(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: BouncingLoader());
}
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),
// LDR-003: Current user stats card at top of leaderboard
if (provider.currentUserStats != null)
Builder(builder: (context) {
final stats = provider.currentUserStats!;
return GlassCard(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatChip('Rank', '#${stats.rank}', Icons.leaderboard),
Container(width: 1, height: 32, color: Colors.white12),
_buildStatChip('EP', '${stats.points}', Icons.bolt),
Container(width: 1, height: 32, color: Colors.white12),
_buildStatChip('Cycle', '${stats.rewardCycleDays}d', Icons.timelapse),
],
),
);
}),
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+) with stagger animation
SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) {
final entry = entries.length > 3 ? entries[i + 3] : entries[i];
return AnimationConfiguration.staggeredList(
position: i,
duration: const Duration(milliseconds: 375),
child: SlideAnimation(
verticalOffset: 40.0,
child: FadeInAnimation(child: _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: [
// GAM-006: Avatar with tier ring + rank badge overlaid
Stack(
clipBehavior: Clip.none,
children: [
// TierAvatarRing — tier-coloured glow ring
TierAvatarRing(
username: e.username,
tier: tierLabel(e.tier),
size: avatarSize,
imageUrl: e.avatarUrl,
),
// 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 GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ContributorProfileScreen(
contributorId: entry.username,
contributorName: entry.username,
),
),
),
child: 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),
),
),
),
// GAM-006: TierAvatarRing replaces plain avatar circle
TierAvatarRing(
username: entry.username,
tier: tierLabel(entry.tier),
size: 36,
imageUrl: entry.avatarUrl,
),
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: () {
final gam = context.read<GamificationProvider>();
showDialog(
context: context,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
child: ShareRankCard(
username: me.username,
tier: tierLabel(me.tier),
rank: me.rank,
ep: me.lifetimeEp,
rewardPoints: gam.profile?.currentRp ?? 0,
),
),
);
},
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)),
],
),
),
),
],
),
);
}
// LDR-003: Stat chip helper for current-user leaderboard card
Widget _buildStatChip(String label, String value, IconData icon) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: const Color(0xFF94A3B8)),
const SizedBox(height: 2),
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
Text(label, style: const TextStyle(fontSize: 10, color: Color(0xFF64748B))),
],
);
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 2 — ACHIEVEMENTS
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildAchievementsTab(BuildContext context, GamificationProvider provider) {
final theme = Theme.of(context);
if (provider.isLoading && provider.achievements.isEmpty) {
return const Center(child: BouncingLoader());
}
final badges = provider.achievements;
final bottomInset = MediaQuery.of(context).padding.bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16, 16, 16, 32 + bottomInset),
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: BouncingLoader());
}
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(userFriendlyError(e)), backgroundColor: Colors.red));
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// GAM-002: EP stat card helper (used in left panel + profile)
// ─────────────────────────────────────────────────────────────────────────
Widget _epStatCard(String label, String value, IconData icon, Color color) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(value, style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(color: Colors.white60, fontSize: 10), textAlign: TextAlign.center),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// GAM-005: Horizontal tier roadmap (Bronze → Diamond)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildTierRoadmap(int lifetimeEp, ContributorTier currentTier) {
const tiers = ContributorTier.values; // BRONZE, SILVER, GOLD, PLATINUM, DIAMOND
const thresholds = [0, 100, 500, 1500, 5000];
final overallProgress = (lifetimeEp / 5000).clamp(0.0, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.06),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Tier Roadmap', style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
Row(
children: List.generate(tiers.length, (i) {
final reached = currentTier.index >= i;
final color = _tierColors[tiers[i]] ?? Colors.grey;
return Expanded(
child: Column(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: reached ? color : Colors.white24,
border: Border.all(color: reached ? color : Colors.white30, width: 2),
),
),
const SizedBox(height: 4),
Text(
tierLabel(tiers[i]),
style: TextStyle(color: reached ? Colors.white : Colors.white38, fontSize: 9, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
Text(
'${thresholds[i]}',
style: TextStyle(color: reached ? Colors.white54 : Colors.white24, fontSize: 8),
),
],
),
);
}),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: overallProgress,
minHeight: 4,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(_tierColors[currentTier] ?? Colors.white),
),
),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// CTR-001: Status chip for submissions
// ─────────────────────────────────────────────────────────────────────────
static Widget statusChip(String status) {
Color bg;
Color fg;
switch (status.toUpperCase()) {
case 'APPROVED':
bg = const Color(0xFFDCFCE7);
fg = const Color(0xFF16A34A);
break;
case 'REJECTED':
bg = const Color(0xFFFEE2E2);
fg = const Color(0xFFDC2626);
break;
default: // PENDING
bg = const Color(0xFFFEF9C3);
fg = const Color(0xFFCA8A04);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Text(
status.toUpperCase(),
style: TextStyle(color: fg, fontWeight: FontWeight.w700, fontSize: 11),
),
);
}
}