Files
Eventify-frontend/lib/screens/contribute_screen.dart
Sicherhaven b24df66b31 feat: rewrite contribute tab to match web app (app.eventifyplus.com/contribute)
Complete UI rewrite of contribute_screen.dart:
- 3 tabs (My Events, Submit Event, Reward Shop) replacing old 4-tab
  layout (Contribute, Leaderboard, Achievements, Shop)
- Compact stats bar: tier pill + liquid EP + RP + share button
- Horizontal tier roadmap showing Bronze→Diamond progression
- Animated tab glider with elastic curve
- Submit form matching web: Event Name, Category, District, Date+Time,
  Description (with EP hint), Location Coordinates (manual lat/lng OR
  Google Maps URL extraction), Media Upload (5 images, 2 EP each)
- My Events tab with status badges (Approved/Pending/Rejected)
- Reward Shop "Coming Soon" with ghost teaser cards
- Color palette matching web: #0F45CF primary, #ea580c RP orange
- File reduced from 2681 to 1093 lines (59% smaller)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:31:25 +05:30

1407 lines
53 KiB
Dart

// lib/screens/contribute_screen.dart
// Contributor Module v3 — matches web at app.eventifyplus.com/contribute
// 3 tabs: My Events · Submit Event · Reward Shop
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../core/utils/error_utils.dart';
import '../features/gamification/models/gamification_models.dart';
import '../features/gamification/providers/gamification_provider.dart';
import '../widgets/bouncing_loader.dart';
import '../core/analytics/posthog_service.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────
const _tierColors = <ContributorTier, Color>{
ContributorTier.BRONZE: Color(0xFFCD7F32),
ContributorTier.SILVER: Color(0xFFA8A9AD),
ContributorTier.GOLD: Color(0xFFFFD700),
ContributorTier.PLATINUM: Color(0xFFE5E4E2),
ContributorTier.DIAMOND: Color(0xFF67E8F9),
};
const _tierIcons = <ContributorTier, IconData>{
ContributorTier.BRONZE: Icons.shield_outlined,
ContributorTier.SILVER: Icons.shield_outlined,
ContributorTier.GOLD: Icons.workspace_premium_outlined,
ContributorTier.PLATINUM: Icons.diamond_outlined,
ContributorTier.DIAMOND: Icons.diamond_outlined,
};
const _tierThresholds = [0, 100, 500, 1500, 5000];
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',
];
// Design tokens matching web
const _blue = Color(0xFF0F45CF);
const _darkText = Color(0xFF1E293B);
const _subText = Color(0xFF94A3B8);
const _border = Color(0xFFE2E8F0);
const _lightBlueBg = Color(0xFFEFF6FF);
const _rpOrange = Color(0xFFEA580C);
const _greenBg = Color(0xFFD1FAE5);
const _greenText = Color(0xFF065F46);
const _yellowBg = Color(0xFFFEF3C7);
const _yellowText = Color(0xFF92400E);
const _redBg = Color(0xFFFECDD3);
const _redText = Color(0xFF9F1239);
const _pageBg = Color(0xFFF8FAFC);
// ─────────────────────────────────────────────────────────────────────────────
// ContributeScreen
// ─────────────────────────────────────────────────────────────────────────────
class ContributeScreen extends StatefulWidget {
const ContributeScreen({Key? key}) : super(key: key);
@override
State<ContributeScreen> createState() => _ContributeScreenState();
}
class _ContributeScreenState extends State<ContributeScreen>
with SingleTickerProviderStateMixin {
int _activeTab = 1; // default to Submit Event
// Form
final _formKey = GlobalKey<FormState>();
final _titleCtl = TextEditingController();
final _descriptionCtl = TextEditingController();
final _latCtl = TextEditingController();
final _lngCtl = TextEditingController();
final _mapsLinkCtl = TextEditingController();
DateTime? _selectedDate;
TimeOfDay? _selectedTime;
String _selectedCategory = _categories.first;
String _selectedDistrict = _districts.first;
List<XFile> _images = [];
bool _submitting = false;
bool _showSuccess = false;
bool _useManualCoords = true;
String? _coordMessage;
bool _coordSuccess = 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();
_descriptionCtl.dispose();
_latCtl.dispose();
_lngCtl.dispose();
_mapsLinkCtl.dispose();
super.dispose();
}
// ─────────────────────────────────────────────────────────────────────────
// Build
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Consumer<GamificationProvider>(
builder: (context, provider, _) {
if (provider.isLoading && provider.profile == null) {
return const Scaffold(
backgroundColor: _pageBg,
body: Center(child: BouncingLoader(color: _blue)),
);
}
return Scaffold(
backgroundColor: _pageBg,
body: SafeArea(
child: Column(
children: [
_buildStatsBar(provider),
_buildTierRoadmap(provider),
_buildTabBar(),
Expanded(child: _buildTabContent(provider)),
],
),
),
);
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. COMPACT STATS BAR
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildStatsBar(GamificationProvider provider) {
final profile = provider.profile;
final tier = profile?.tier ?? ContributorTier.BRONZE;
final tierColor = _tierColors[tier] ?? const Color(0xFFCD7F32);
final tierIcon = _tierIcons[tier] ?? Icons.shield_outlined;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
// Tier pill
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _blue,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(tierIcon, color: tierColor, size: 16),
const SizedBox(width: 6),
Text(
tierLabel(tier),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
),
],
),
),
const SizedBox(width: 12),
// Liquid EP
Icon(Icons.bolt, color: _blue, size: 18),
const SizedBox(width: 4),
Text(
'${profile?.currentEp ?? 0}',
style: const TextStyle(color: _darkText, fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(width: 4),
const Text('EP', style: TextStyle(color: _subText, fontSize: 12)),
const SizedBox(width: 16),
// RP
Icon(Icons.card_giftcard, color: _rpOrange, size: 18),
const SizedBox(width: 4),
Text(
'${profile?.currentRp ?? 0}',
style: TextStyle(color: _rpOrange, fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(width: 4),
const Text('RP', style: TextStyle(color: _subText, fontSize: 12)),
const Spacer(),
// Share button
GestureDetector(
onTap: () => _shareRank(provider),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.share_outlined, color: _subText, size: 18),
),
),
],
),
);
}
void _shareRank(GamificationProvider provider) {
final profile = provider.profile;
if (profile == null) return;
final text = "I'm a ${tierLabel(profile.tier)} contributor on Eventify with ${profile.lifetimeEp} EP! Join the community.";
Share.share(text);
}
// ═══════════════════════════════════════════════════════════════════════════
// 2. TIER ROADMAP
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildTierRoadmap(GamificationProvider provider) {
final currentTier = provider.profile?.tier ?? ContributorTier.BRONZE;
final tiers = ContributorTier.values;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _border),
),
child: Row(
children: List.generate(tiers.length * 2 - 1, (i) {
if (i.isOdd) {
// Connector line
final tierIdx = i ~/ 2;
final reached = currentTier.index > tierIdx;
return Expanded(
child: Container(
height: 2,
color: reached ? _blue : const Color(0xFFE2E8F0),
),
);
}
final tierIdx = i ~/ 2;
final tier = tiers[tierIdx];
final isActive = tier == currentTier;
final reached = currentTier.index >= tierIdx;
final tierColor = _tierColors[tier]!;
final thresholdLabel = _tierThresholds[tierIdx] >= 1000
? '${(_tierThresholds[tierIdx] / 1000).toStringAsFixed(_tierThresholds[tierIdx] % 1000 == 0 ? 0 : 1)}K'
: '${_tierThresholds[tierIdx]}';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isActive ? _lightBlueBg : (reached ? _blue.withValues(alpha: 0.08) : const Color(0xFFF8FAFC)),
shape: BoxShape.circle,
border: Border.all(
color: isActive ? _blue : (reached ? _blue.withValues(alpha: 0.3) : _border),
width: isActive ? 2 : 1,
),
),
child: Icon(
_tierIcons[tier] ?? Icons.shield_outlined,
size: 16,
color: reached ? _blue : _subText,
),
),
const SizedBox(height: 4),
Text(
tierLabel(tier),
style: TextStyle(
fontSize: 9,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: isActive ? _blue : _subText,
),
),
Text(
'$thresholdLabel EP',
style: TextStyle(fontSize: 8, color: isActive ? _blue : const Color(0xFFCBD5E1)),
),
],
);
}),
),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. TAB BAR WITH ANIMATED GLIDER
// ═══════════════════════════════════════════════════════════════════════════
static const _tabLabels = ['My Events', 'Submit Event', 'Reward Shop'];
static const _tabIcons = [Icons.list_alt_rounded, Icons.add_circle_outline, Icons.card_giftcard_outlined];
Widget _buildTabBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _border),
),
child: LayoutBuilder(
builder: (context, constraints) {
final tabWidth = constraints.maxWidth / 3;
return Stack(
children: [
// Animated glider
AnimatedPositioned(
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutBack,
left: tabWidth * _activeTab + 4,
top: 4,
child: Container(
width: tabWidth - 8,
height: 44,
decoration: BoxDecoration(
color: _blue,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(color: _blue.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2)),
],
),
),
),
// Tab buttons
Row(
children: List.generate(3, (i) {
final isActive = i == _activeTab;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _activeTab = i),
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: 52,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_tabIcons[i],
size: 18,
color: isActive ? Colors.white : const Color(0xFF64748B),
),
const SizedBox(width: 6),
Text(
_tabLabels[i],
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isActive ? Colors.white : const Color(0xFF64748B),
),
),
],
),
),
),
);
}),
),
],
);
},
),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 4. TAB CONTENT
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildTabContent(GamificationProvider provider) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
border: Border.all(color: _border),
),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: _activeTab == 0
? _buildMyEventsTab(provider)
: _activeTab == 1
? _buildSubmitEventTab(provider)
: _buildRewardShopTab(provider),
),
),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 0: MY EVENTS
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildMyEventsTab(GamificationProvider provider) {
final submissions = provider.submissions;
if (submissions.isEmpty) {
return Center(
key: const ValueKey('empty'),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: _lightBlueBg,
shape: BoxShape.circle,
),
child: const Icon(Icons.star_outline_rounded, color: _blue, size: 32),
),
const SizedBox(height: 16),
const Text(
'No events submitted yet',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: _darkText),
),
const SizedBox(height: 8),
const Text(
'Head over to the Submit tab to earn your first EP!',
style: TextStyle(fontSize: 13, color: _subText),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _activeTab = 1),
style: ElevatedButton.styleFrom(
backgroundColor: _blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text('Start Contributing', style: TextStyle(fontWeight: FontWeight.w600)),
),
],
),
),
);
}
return ListView.separated(
key: const ValueKey('list'),
padding: const EdgeInsets.all(16),
itemCount: submissions.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) => _buildSubmissionCard(submissions[i]),
);
}
Widget _buildSubmissionCard(SubmissionModel sub) {
Color statusBg, statusFg;
String statusLabel;
switch (sub.status.toUpperCase()) {
case 'APPROVED':
statusBg = _greenBg; statusFg = _greenText; statusLabel = 'Approved';
break;
case 'REJECTED':
statusBg = _redBg; statusFg = _redText; statusLabel = 'Rejected';
break;
default:
statusBg = _yellowBg; statusFg = _yellowText; statusLabel = 'Pending';
}
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
sub.eventName,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: _darkText),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
if (sub.category.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _lightBlueBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
sub.category,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: _blue),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.calendar_today_outlined, size: 14, color: _subText),
const SizedBox(width: 4),
Text(
DateFormat('d MMM yyyy').format(sub.createdAt),
style: const TextStyle(fontSize: 12, color: _subText),
),
if (sub.district != null && sub.district!.isNotEmpty) ...[
const SizedBox(width: 12),
Icon(Icons.location_on_outlined, size: 14, color: _subText),
const SizedBox(width: 4),
Text(sub.district!, style: const TextStyle(fontSize: 12, color: _subText)),
],
const Spacer(),
// Status badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(statusLabel, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: statusFg)),
),
],
),
const SizedBox(height: 6),
Row(
children: [
const Text('EP Earned: ', style: TextStyle(fontSize: 12, color: _subText)),
Text(
sub.status.toUpperCase() == 'APPROVED' ? '${sub.epAwarded}' : '-',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: sub.status.toUpperCase() == 'APPROVED' ? _blue : _subText,
),
),
],
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 1: SUBMIT EVENT
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildSubmitEventTab(GamificationProvider provider) {
if (_showSuccess) return _buildSuccessState();
return SingleChildScrollView(
key: const ValueKey('submit'),
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Submit a New Event',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: _darkText),
),
const SizedBox(height: 4),
const Text(
'Provide accurate details to maximize your evaluated EP points.',
style: TextStyle(fontSize: 13, color: _subText),
),
const SizedBox(height: 20),
// Event Name
_inputLabel('Event Name', required: true),
const SizedBox(height: 6),
_textField(_titleCtl, 'e.g. Cochin Carnival 2026',
validator: (v) => (v == null || v.trim().isEmpty) ? 'Event name is required' : null),
const SizedBox(height: 16),
// Category + District row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_inputLabel('Category', required: true),
const SizedBox(height: 6),
_dropdown(_selectedCategory, _categories, (v) => setState(() => _selectedCategory = v!)),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_inputLabel('District', required: true),
const SizedBox(height: 6),
_dropdown(_selectedDistrict, _districts, (v) => setState(() => _selectedDistrict = v!)),
],
),
),
],
),
const SizedBox(height: 16),
// Date & Time row
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_inputLabel('Date', required: true),
const SizedBox(height: 6),
_datePicker(),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_inputLabel('Time'),
const SizedBox(height: 6),
_timePicker(),
],
),
),
],
),
const SizedBox(height: 16),
// Description
_inputLabel('Description', required: true, hint: '(Required for higher EP)'),
const SizedBox(height: 6),
_textField(_descriptionCtl, 'Include agenda, ticket details, organizer contact, etc...',
maxLines: 4,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Description is required' : null),
const SizedBox(height: 16),
// Location Coordinates
_inputLabel('Location Coordinates'),
const SizedBox(height: 6),
_buildCoordinateInput(),
const SizedBox(height: 16),
// Media Upload
_buildMediaUpload(),
const SizedBox(height: 24),
// Submit button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _submitting ? null : () => _submitForm(provider),
style: ElevatedButton.styleFrom(
backgroundColor: _blue,
foregroundColor: Colors.white,
disabledBackgroundColor: _blue.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
child: _submitting
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Submit for Review', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
SizedBox(width: 8),
Icon(Icons.arrow_forward_rounded, size: 18),
],
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
// ── Form helpers ──────────────────────────────────────────────────────────
Widget _inputLabel(String text, {bool required = false, String? hint}) {
return Row(
children: [
Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: _darkText)),
if (required) const Text(' *', style: TextStyle(color: Color(0xFFEF4444), fontSize: 13)),
if (hint != null) ...[
const SizedBox(width: 4),
Text(hint, style: const TextStyle(fontSize: 11, color: _subText)),
],
],
);
}
Widget _textField(TextEditingController ctl, String placeholder, {
int maxLines = 1,
String? Function(String?)? validator,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
}) {
return TextFormField(
controller: ctl,
maxLines: maxLines,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
style: const TextStyle(fontSize: 14, color: _darkText),
decoration: InputDecoration(
hintText: placeholder,
hintStyle: const TextStyle(color: Color(0xFFCBD5E1), fontSize: 14),
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _border)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _blue, width: 1.5)),
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFEF4444))),
),
);
}
Widget _dropdown(String value, List<String> items, ValueChanged<String?> onChanged) {
return DropdownButtonFormField<String>(
value: value,
items: items.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 14)))).toList(),
onChanged: onChanged,
decoration: InputDecoration(
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _border)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _blue, width: 1.5)),
),
dropdownColor: Colors.white,
style: const TextStyle(fontSize: 14, color: _darkText),
icon: const Icon(Icons.keyboard_arrow_down_rounded, color: _subText),
);
}
Widget _datePicker() {
return GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _selectedDate = picked);
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
),
child: Row(
children: [
Expanded(
child: Text(
_selectedDate != null ? DateFormat('d MMM yyyy').format(_selectedDate!) : 'Select date',
style: TextStyle(
fontSize: 14,
color: _selectedDate != null ? _darkText : const Color(0xFFCBD5E1),
),
),
),
const Icon(Icons.calendar_today_outlined, color: _subText, size: 18),
],
),
),
);
}
Widget _timePicker() {
return GestureDetector(
onTap: () async {
final picked = await showTimePicker(
context: context,
initialTime: _selectedTime ?? TimeOfDay.now(),
);
if (picked != null) setState(() => _selectedTime = picked);
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
),
child: Row(
children: [
Expanded(
child: Text(
_selectedTime != null ? _selectedTime!.format(context) : 'Select time',
style: TextStyle(
fontSize: 14,
color: _selectedTime != null ? _darkText : const Color(0xFFCBD5E1),
),
),
),
const Icon(Icons.access_time_outlined, color: _subText, size: 18),
],
),
),
);
}
// ── Coordinate input (Manual / Google Maps Link toggle) ──────────────────
Widget _buildCoordinateInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Toggle tabs
Row(
children: [
_coordToggle('Manual', _useManualCoords, () => setState(() => _useManualCoords = true)),
const SizedBox(width: 8),
_coordToggle('Google Maps Link', !_useManualCoords, () => setState(() => _useManualCoords = false)),
],
),
const SizedBox(height: 10),
if (_useManualCoords) ...[
Row(
children: [
Expanded(
child: _textField(_latCtl, 'Latitude (e.g. 9.93123)',
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]'))]),
),
const SizedBox(width: 12),
Expanded(
child: _textField(_lngCtl, 'Longitude (e.g. 76.26730)',
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]'))]),
),
],
),
] else ...[
Row(
children: [
Expanded(
child: _textField(_mapsLinkCtl, 'Paste Google Maps URL here'),
),
const SizedBox(width: 8),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _extractCoordinates,
style: ElevatedButton.styleFrom(
backgroundColor: _blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16),
),
child: const Text('Extract', style: TextStyle(fontWeight: FontWeight.w600)),
),
),
],
),
if (_coordMessage != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
_coordSuccess ? Icons.check_circle : Icons.error_outline,
size: 16,
color: _coordSuccess ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
),
const SizedBox(width: 6),
Expanded(
child: Text(
_coordMessage!,
style: TextStyle(
fontSize: 12,
color: _coordSuccess ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
),
),
),
],
),
],
],
],
);
}
Widget _coordToggle(String label, bool active, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: active ? _blue : const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: active ? _blue : _border),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: active ? Colors.white : _subText,
),
),
),
);
}
void _extractCoordinates() {
final url = _mapsLinkCtl.text.trim();
if (url.isEmpty) {
setState(() { _coordMessage = 'Please paste a Google Maps URL'; _coordSuccess = false; });
return;
}
double? lat, lng;
// Pattern 1: @lat,lng
final atMatch = RegExp(r'@(-?\d+\.?\d*),(-?\d+\.?\d*)').firstMatch(url);
if (atMatch != null) {
lat = double.tryParse(atMatch.group(1)!);
lng = double.tryParse(atMatch.group(2)!);
}
// Pattern 2: 3dlat!4dlng
if (lat == null) {
final dMatch = RegExp(r'3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)').firstMatch(url);
if (dMatch != null) {
lat = double.tryParse(dMatch.group(1)!);
lng = double.tryParse(dMatch.group(2)!);
}
}
// Pattern 3: q=lat,lng
if (lat == null) {
final qMatch = RegExp(r'[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)').firstMatch(url);
if (qMatch != null) {
lat = double.tryParse(qMatch.group(1)!);
lng = double.tryParse(qMatch.group(2)!);
}
}
// Pattern 4: ll=lat,lng
if (lat == null) {
final llMatch = RegExp(r'[?&]ll=(-?\d+\.?\d*),(-?\d+\.?\d*)').firstMatch(url);
if (llMatch != null) {
lat = double.tryParse(llMatch.group(1)!);
lng = double.tryParse(llMatch.group(2)!);
}
}
if (lat != null && lng != null) {
_latCtl.text = lat.toStringAsFixed(6);
_lngCtl.text = lng.toStringAsFixed(6);
setState(() {
_coordMessage = 'Coordinates extracted: $lat, $lng';
_coordSuccess = true;
});
} else {
setState(() {
_coordMessage = 'Could not extract coordinates from this URL';
_coordSuccess = false;
});
}
}
// ── Media upload ──────────────────────────────────────────────────────────
Widget _buildMediaUpload() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_inputLabel('Photos'),
const Spacer(),
Text('${_images.length}/5', style: const TextStyle(fontSize: 12, color: _subText)),
],
),
const SizedBox(height: 4),
const Text(
'2 EP per image, max 5 EP',
style: TextStyle(fontSize: 11, color: _subText),
),
const SizedBox(height: 8),
// Upload area
if (_images.length < 5)
GestureDetector(
onTap: _pickImages,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFCBD5E1), width: 2, strokeAlign: BorderSide.strokeAlignInside),
),
child: Column(
children: [
Icon(Icons.cloud_upload_outlined, color: _blue, size: 28),
const SizedBox(height: 6),
const Text('Tap to add photos', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: _darkText)),
const SizedBox(height: 2),
const Text('JPEG, PNG, WebP', style: TextStyle(fontSize: 11, color: _subText)),
],
),
),
),
// Thumbnail gallery
if (_images.isNotEmpty) ...[
const SizedBox(height: 10),
SizedBox(
height: 80,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _images.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (ctx, i) => _buildImageThumb(i),
),
),
],
],
);
}
Widget _buildImageThumb(int index) {
final img = _images[index];
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _border),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: kIsWeb
? const Center(child: Icon(Icons.image, color: _subText, size: 28))
: Image.file(File(img.path), fit: BoxFit.cover),
),
),
Positioned(
top: -6,
right: -6,
child: GestureDetector(
onTap: () => setState(() => _images.removeAt(index)),
child: Container(
width: 22,
height: 22,
decoration: const BoxDecoration(
color: Color(0xFFEF4444),
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 14),
),
),
),
],
);
}
Future<void> _pickImages() async {
try {
final picked = await _picker.pickMultiImage(imageQuality: 80);
if (picked.isNotEmpty) {
setState(() {
final remaining = 5 - _images.length;
_images.addAll(picked.take(remaining));
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not pick images: ${userFriendlyError(e)}')),
);
}
}
}
// ── Success state ─────────────────────────────────────────────────────────
Widget _buildSuccessState() {
return Center(
key: const ValueKey('success'),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 500),
curve: Curves.elasticOut,
builder: (_, v, child) => Transform.scale(scale: v, child: child),
child: Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: _greenBg,
shape: BoxShape.circle,
),
child: const Icon(Icons.check_rounded, color: Color(0xFF22C55E), size: 44),
),
),
const SizedBox(height: 20),
const Text(
'Event Submitted!',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: _darkText),
),
const SizedBox(height: 8),
const Text(
'Thank you for contributing. It is now pending\nadmin verification. You can earn up to 10 EP!',
style: TextStyle(fontSize: 13, color: _subText),
textAlign: TextAlign.center,
),
],
),
);
}
// ── Submit handler ────────────────────────────────────────────────────────
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'), backgroundColor: Color(0xFFEF4444)),
);
return;
}
setState(() => _submitting = true);
try {
final data = <String, dynamic>{
'title': _titleCtl.text.trim(),
'category': _selectedCategory,
'district': _selectedDistrict,
'date': _selectedDate!.toIso8601String(),
'time': _selectedTime?.format(context),
'description': _descriptionCtl.text.trim(),
'images': _images.map((f) => f.path).toList(),
};
// Add coordinates if provided
final lat = double.tryParse(_latCtl.text.trim());
final lng = double.tryParse(_lngCtl.text.trim());
if (lat != null && lng != null) {
data['location_lat'] = lat;
data['location_lng'] = lng;
}
await provider.submitContribution(data);
PostHogService.instance.capture('event_contributed', properties: {
'category': _selectedCategory,
'district': _selectedDistrict,
'has_images': _images.isNotEmpty,
'image_count': _images.length,
'has_coordinates': lat != null && lng != null,
});
// Show success, then reset
setState(() { _submitting = false; _showSuccess = true; });
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
_clearForm();
setState(() { _showSuccess = false; _activeTab = 0; });
provider.loadAll(force: true);
}
} catch (e) {
if (mounted) {
setState(() => _submitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(userFriendlyError(e)), backgroundColor: const Color(0xFFEF4444)),
);
}
}
}
void _clearForm() {
_titleCtl.clear();
_descriptionCtl.clear();
_latCtl.clear();
_lngCtl.clear();
_mapsLinkCtl.clear();
_selectedDate = null;
_selectedTime = null;
_selectedCategory = _categories.first;
_selectedDistrict = _districts.first;
_images.clear();
_coordMessage = null;
_useManualCoords = true;
}
// ═══════════════════════════════════════════════════════════════════════════
// TAB 2: REWARD SHOP (Coming Soon)
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildRewardShopTab(GamificationProvider provider) {
final rp = provider.profile?.currentRp ?? 0;
return SingleChildScrollView(
key: const ValueKey('shop'),
padding: const EdgeInsets.all(20),
child: Column(
children: [
const SizedBox(height: 20),
// RP balance
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFFF7ED),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _rpOrange.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.card_giftcard, color: _rpOrange, size: 20),
const SizedBox(width: 8),
Text(
'$rp RP',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: _rpOrange),
),
const SizedBox(width: 6),
const Text('available', style: TextStyle(fontSize: 12, color: _subText)),
],
),
),
const SizedBox(height: 32),
// Pulsing icon
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.95, end: 1.05),
duration: const Duration(seconds: 2),
curve: Curves.easeInOut,
builder: (_, v, child) => Transform.scale(scale: v, child: child),
onEnd: () {}, // AnimationBuilder loops via repeat — handled below
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [_blue.withValues(alpha: 0.15), _blue.withValues(alpha: 0.05)],
),
shape: BoxShape.circle,
),
child: const Icon(Icons.card_giftcard_rounded, color: _blue, size: 36),
),
),
const SizedBox(height: 20),
// Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: _lightBlueBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _blue.withValues(alpha: 0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(color: _blue, shape: BoxShape.circle),
),
const SizedBox(width: 8),
const Text('Coming Soon', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: _blue)),
],
),
),
const SizedBox(height: 16),
const Text(
"We're Stocking the Shelves",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: _darkText),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Redeem your RP for event vouchers, VIP passes, exclusive merch, and more. Keep contributing to build your balance!',
style: TextStyle(fontSize: 13, color: _subText, height: 1.5),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
// Ghost teaser cards
..._buildGhostCards(),
const SizedBox(height: 20),
],
),
);
}
List<Widget> _buildGhostCards() {
const items = [
{'name': 'Event Voucher', 'rp': '500', 'icon': Icons.confirmation_number_outlined},
{'name': 'VIP Access Pass', 'rp': '1,200', 'icon': Icons.verified_outlined},
{'name': 'Exclusive Merch', 'rp': '2,000', 'icon': Icons.shopping_bag_outlined},
];
return items.map((item) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Opacity(
opacity: 0.45,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _border),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: _lightBlueBg,
borderRadius: BorderRadius.circular(10),
),
child: Icon(item['icon'] as IconData, color: _blue, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['name'] as String,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: _darkText),
),
const SizedBox(height: 2),
Text(
'${item['rp']} RP',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: _rpOrange),
),
],
),
),
const Icon(Icons.lock_outline_rounded, color: _subText, size: 20),
],
),
),
),
);
}).toList();
}
}