- 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
2995 lines
124 KiB
Dart
2995 lines
124 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 '../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, 06–11)
|
||
'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),
|
||
),
|
||
);
|
||
}
|
||
}
|