diff --git a/.gitignore b/.gitignore
index 1cc43ec..3ec4982 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,7 @@ app.*.map.json
/android/app/profile
/android/app/release
web/assets/login-bg.mp4
+
+# Keystore files (signing keys)
+*.jks
+*.keystore
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..bf8ac74
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,71 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+
+---
+
+## [1.4.0] - 2026-03-18
+
+### Added
+- **Desktop Contribute Dashboard**: Full desktop layout for the Contribute screen, matching the web version at eventifyplus.com/contribute
+ - "Contributor Dashboard" title with 3-tab navigation (Contribute, Leaderboard, Achievements)
+ - Two-column submit event form — Event Title + Category side-by-side, Date + Location side-by-side
+ - Contributor Level gradient card with 5-tier milestone progress bar (Bronze → Silver → Gold → Platinum → Diamond)
+ - Sub-navigation row: My Events / Submit Event / Reward Shop
+ - Desktop Leaderboard with All Time / This Month toggle, district pills, podium, and full rank table
+ - Desktop Achievements with 3-column badge grid, progress bars, and lock icons
+ - Inline Reward Shop with RP balance badge and shop item cards
+- **Gamification Feature Module** (`lib/features/gamification/`):
+ - `GamificationProvider` — ChangeNotifier-based state management
+ - `GamificationService` — mock data for EP, RP, leaderboard entries, achievements, and shop items
+ - Models: `LeaderboardUser`, `Achievement`, `ShopItem`, `ContributorStats`
+- **Bottom Sheet Date Filters**: Home screen event-category filter chips now open in a modal bottom sheet on mobile
+- Web runner script (`run_web.sh`) for local Flutter web development server
+
+### Changed
+- **Profile Screen**: Completely redesigned to match the web app layout — gradient header card, avatar, stats row (Likes / Posts / Views), and tabbed content
+- **Profile Card Animations**: Smooth entrance animations matching the React web component
+- **Contribute Screen (Mobile)**: Full 4-tab rebuild — Contribute, Leaderboard, Achievements, Shop — with animated glass-glider tab bar indicator
+- **Login Screen**: Updated UI design aligned with the web version
+- **Event Detail Screen**: Layout updates and improved API data binding
+- **Theme**: Refreshed dark/light mode colour palette and surface colours
+- **API Client**: Updated base URL and endpoint paths in `lib/core/api/api_endpoints.dart`
+- **Fonts**: Integrated full Gilroy font family (Light, Regular, Medium, SemiBold, Bold, ExtraBold — with italic variants)
+- **Responsive Layout**: Improved breakpoint handling; desktop threshold set at 820 px
+
+### Fixed
+- Profile card pixel-perfect alignment with the web version
+- Calendar screen date-range filter and location search integration
+- District dropdown naming conflict in leaderboard (`_lbDistricts` vs. `_districts`)
+- Green points colour (#16A34A) on desktop leaderboard matching web (was blue #0F45CF on mobile)
+
+---
+
+## [1.3.0] - 2026-02-xx
+
+### Added
+- Leaderboard tab and Achievements tab added to the Contribute screen
+- Bouncy sliding glass-glider animation for Contribute tab bar
+
+---
+
+## [1.2.0] - 2026-01-xx
+
+### Added
+- Responsive dual-layout system with 820 px breakpoint
+- Date filtering on the Home screen event feed
+- Location search integration
+- Calendar screen bug fixes and improvements
+
+---
+
+## [1.1.0] - 2025-12-xx
+
+### Added
+- Initial screens: Login, Home, Events, Profile, Calendar, Search, Booking
+- Desktop variants: `DesktopLoginScreen`, `HomeDesktopScreen`
+- Flutter launcher icons and native splash screen
+- Gilroy font integration (initial)
+- `shared_preferences` session caching
diff --git a/README.md b/README.md
index 112d3c7..399332f 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@
[](https://flutter.dev/)
[](https://dart.dev/)
[](#)
+[](#)
@@ -123,6 +124,18 @@ The app uses an initialization check in `main.dart` that intercepts the launch v
---
+## 📋 Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md) for a full history of changes.
+
+### Latest (v1.4.0 — Preview)
+- **Desktop Contribute Dashboard** rebuilt to match the web version (Contributor Dashboard, 3-tab nav, two-column form, leaderboard, achievements, reward shop)
+- **Gamification module** — EP, RP, leaderboard, achievements, shop with Provider state management
+- **Profile screen** redesigned to match the web app layout with animations
+- Enhanced animations, responsive improvements, Gilroy font suite, and API updates
+
+---
+
Built with ❤️ by the Eventify Team
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index e23a2f2..79b05bd 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -22,8 +22,8 @@ android {
applicationId = "com.sicherhaven.eventify"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
- versionCode = 11
- versionName = "1.2(p)"
+ versionCode = 14
+ versionName = "1.4(p)"
}
// ---------- SIGNING CONFIG ----------
diff --git a/lib/core/api/api_endpoints.dart b/lib/core/api/api_endpoints.dart
index 7ce37e8..95fa6eb 100644
--- a/lib/core/api/api_endpoints.dart
+++ b/lib/core/api/api_endpoints.dart
@@ -23,4 +23,12 @@ class ApiEndpoints {
// static const String bookEvent = "$baseUrl/events/book-event/";
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
+
+ // Gamification / Contributor Module (TechDocs v2)
+ static const String gamificationProfile = "$baseUrl/v1/user/gamification-profile/";
+ static const String leaderboard = "$baseUrl/v1/leaderboard/";
+ static const String shopItems = "$baseUrl/v1/shop/items/";
+ static const String shopRedeem = "$baseUrl/v1/shop/redeem/";
+ static const String contributeSubmit = "$baseUrl/v1/contributions/submit/";
+ static const String gradeContribution = "$baseUrl/v1/admin/contributions/"; // append {id}/grade/
}
diff --git a/lib/features/gamification/models/gamification_models.dart b/lib/features/gamification/models/gamification_models.dart
new file mode 100644
index 0000000..ee7710d
--- /dev/null
+++ b/lib/features/gamification/models/gamification_models.dart
@@ -0,0 +1,212 @@
+// lib/features/gamification/models/gamification_models.dart
+// Data models matching TechDocs v2 DB schema for the Contributor Module.
+
+// ---------------------------------------------------------------------------
+// Tier enum — matches PRD v3 §4.4 thresholds (based on Lifetime EP)
+// ---------------------------------------------------------------------------
+enum ContributorTier { BRONZE, SILVER, GOLD, PLATINUM, DIAMOND }
+
+/// Returns the correct tier for a given lifetime EP total.
+ContributorTier tierFromEp(int lifetimeEp) {
+ if (lifetimeEp >= 5000) return ContributorTier.DIAMOND;
+ if (lifetimeEp >= 1500) return ContributorTier.PLATINUM;
+ if (lifetimeEp >= 500) return ContributorTier.GOLD;
+ if (lifetimeEp >= 100) return ContributorTier.SILVER;
+ return ContributorTier.BRONZE;
+}
+
+/// Human-readable label for a tier.
+String tierLabel(ContributorTier tier) {
+ switch (tier) {
+ case ContributorTier.BRONZE:
+ return 'Bronze';
+ case ContributorTier.SILVER:
+ return 'Silver';
+ case ContributorTier.GOLD:
+ return 'Gold';
+ case ContributorTier.PLATINUM:
+ return 'Platinum';
+ case ContributorTier.DIAMOND:
+ return 'Diamond';
+ }
+}
+
+/// EP threshold for next tier (used for progress bar). Returns null at max tier.
+int? nextTierThreshold(ContributorTier tier) {
+ switch (tier) {
+ case ContributorTier.BRONZE:
+ return 100;
+ case ContributorTier.SILVER:
+ return 500;
+ case ContributorTier.GOLD:
+ return 1500;
+ case ContributorTier.PLATINUM:
+ return 5000;
+ case ContributorTier.DIAMOND:
+ return null;
+ }
+}
+
+/// Lower EP bound for current tier (used for progress bar calculation).
+int tierStartEp(ContributorTier tier) {
+ switch (tier) {
+ case ContributorTier.BRONZE:
+ return 0;
+ case ContributorTier.SILVER:
+ return 100;
+ case ContributorTier.GOLD:
+ return 500;
+ case ContributorTier.PLATINUM:
+ return 1500;
+ case ContributorTier.DIAMOND:
+ return 5000;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// UserGamificationProfile — mirrors the `UserGamificationProfile` DB table
+// ---------------------------------------------------------------------------
+class UserGamificationProfile {
+ final String userId;
+ final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
+ final int currentEp; // Liquid EP accumulated this month.
+ final int currentRp; // Spendable Reward Points.
+ final ContributorTier tier;
+
+ const UserGamificationProfile({
+ required this.userId,
+ required this.lifetimeEp,
+ required this.currentEp,
+ required this.currentRp,
+ required this.tier,
+ });
+
+ factory UserGamificationProfile.fromJson(Map json) {
+ final ep = (json['lifetime_ep'] as int?) ?? 0;
+ return UserGamificationProfile(
+ userId: json['user_id'] as String? ?? '',
+ lifetimeEp: ep,
+ currentEp: (json['current_ep'] as int?) ?? 0,
+ currentRp: (json['current_rp'] as int?) ?? 0,
+ tier: tierFromEp(ep),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// LeaderboardEntry
+// ---------------------------------------------------------------------------
+class LeaderboardEntry {
+ final int rank;
+ final String username;
+ final String? avatarUrl;
+ final int lifetimeEp;
+ final ContributorTier tier;
+ final int eventsCount;
+ final bool isCurrentUser;
+
+ const LeaderboardEntry({
+ required this.rank,
+ required this.username,
+ this.avatarUrl,
+ required this.lifetimeEp,
+ required this.tier,
+ required this.eventsCount,
+ this.isCurrentUser = false,
+ });
+
+ factory LeaderboardEntry.fromJson(Map json) {
+ final ep = (json['lifetime_ep'] as int?) ?? 0;
+ return LeaderboardEntry(
+ rank: (json['rank'] as int?) ?? 0,
+ username: json['username'] as String? ?? '',
+ avatarUrl: json['avatar_url'] as String?,
+ lifetimeEp: ep,
+ tier: tierFromEp(ep),
+ eventsCount: (json['events_count'] as int?) ?? 0,
+ isCurrentUser: (json['is_current_user'] as bool?) ?? false,
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ShopItem — mirrors `RedeemShopItem` table
+// ---------------------------------------------------------------------------
+class ShopItem {
+ final String id;
+ final String name;
+ final String description;
+ final int rpCost;
+ final int stockQuantity;
+ final String? imageUrl;
+
+ const ShopItem({
+ required this.id,
+ required this.name,
+ required this.description,
+ required this.rpCost,
+ required this.stockQuantity,
+ this.imageUrl,
+ });
+
+ factory ShopItem.fromJson(Map json) {
+ return ShopItem(
+ id: json['id'] as String? ?? '',
+ name: json['name'] as String? ?? '',
+ description: json['description'] as String? ?? '',
+ rpCost: (json['rp_cost'] as int?) ?? 0,
+ stockQuantity: (json['stock_quantity'] as int?) ?? 0,
+ imageUrl: json['image_url'] as String?,
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// RedemptionRecord — mirrors `RedemptionHistory` table
+// ---------------------------------------------------------------------------
+class RedemptionRecord {
+ final String id;
+ final String itemId;
+ final int rpSpent;
+ final String voucherCode;
+ final DateTime timestamp;
+
+ const RedemptionRecord({
+ required this.id,
+ required this.itemId,
+ required this.rpSpent,
+ required this.voucherCode,
+ required this.timestamp,
+ });
+
+ factory RedemptionRecord.fromJson(Map json) {
+ return RedemptionRecord(
+ id: json['id'] as String? ?? '',
+ itemId: json['item_id'] as String? ?? '',
+ rpSpent: (json['rp_spent'] as int?) ?? 0,
+ voucherCode: json['voucher_code_issued'] as String? ?? '',
+ timestamp: DateTime.tryParse(json['timestamp'] as String? ?? '') ?? DateTime.now(),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// AchievementBadge
+// ---------------------------------------------------------------------------
+class AchievementBadge {
+ final String id;
+ final String title;
+ final String description;
+ final String iconName; // maps to an IconData key
+ final bool isUnlocked;
+ final double progress; // 0.0 – 1.0
+
+ const AchievementBadge({
+ required this.id,
+ required this.title,
+ required this.description,
+ required this.iconName,
+ required this.isUnlocked,
+ required this.progress,
+ });
+}
diff --git a/lib/features/gamification/providers/gamification_provider.dart b/lib/features/gamification/providers/gamification_provider.dart
new file mode 100644
index 0000000..5b84c3e
--- /dev/null
+++ b/lib/features/gamification/providers/gamification_provider.dart
@@ -0,0 +1,124 @@
+// lib/features/gamification/providers/gamification_provider.dart
+
+import 'package:flutter/foundation.dart';
+import '../models/gamification_models.dart';
+import '../services/gamification_service.dart';
+
+class GamificationProvider extends ChangeNotifier {
+ final GamificationService _service = GamificationService();
+
+ // State
+ UserGamificationProfile? profile;
+ List leaderboard = [];
+ List shopItems = [];
+ List achievements = [];
+
+ // Leaderboard filters — matches web version
+ String leaderboardDistrict = 'Overall Kerala';
+ String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
+
+ bool isLoading = false;
+ String? error;
+
+ // ---------------------------------------------------------------------------
+ // Load everything at once (called when ContributeScreen is mounted)
+ // ---------------------------------------------------------------------------
+ Future loadAll() async {
+ isLoading = true;
+ error = null;
+ notifyListeners();
+
+ try {
+ final results = await Future.wait([
+ _service.getProfile(),
+ _service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
+ _service.getShopItems(),
+ _service.getAchievements(),
+ ]);
+
+ profile = results[0] as UserGamificationProfile;
+ leaderboard = results[1] as List;
+ shopItems = results[2] as List;
+ achievements = results[3] as List;
+ } catch (e) {
+ error = e.toString();
+ } finally {
+ isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Change district filter
+ // ---------------------------------------------------------------------------
+ Future setDistrict(String district) async {
+ if (leaderboardDistrict == district) return;
+ leaderboardDistrict = district;
+ notifyListeners();
+ try {
+ leaderboard = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
+ } catch (e) {
+ error = e.toString();
+ }
+ notifyListeners();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Change time period filter
+ // ---------------------------------------------------------------------------
+ Future setTimePeriod(String period) async {
+ if (leaderboardTimePeriod == period) return;
+ leaderboardTimePeriod = period;
+ notifyListeners();
+ try {
+ leaderboard = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
+ } catch (e) {
+ error = e.toString();
+ }
+ notifyListeners();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Redeem a shop item — deducts RP locally optimistically, returns voucher code
+ // ---------------------------------------------------------------------------
+ Future redeemItem(String itemId) async {
+ final item = shopItems.firstWhere((s) => s.id == itemId);
+
+ // Optimistically deduct RP
+ if (profile != null) {
+ profile = UserGamificationProfile(
+ userId: profile!.userId,
+ lifetimeEp: profile!.lifetimeEp,
+ currentEp: profile!.currentEp,
+ currentRp: profile!.currentRp - item.rpCost,
+ tier: profile!.tier,
+ );
+ notifyListeners();
+ }
+
+ try {
+ final record = await _service.redeemItem(itemId);
+ return record.voucherCode;
+ } catch (e) {
+ // Rollback on failure
+ if (profile != null) {
+ profile = UserGamificationProfile(
+ userId: profile!.userId,
+ lifetimeEp: profile!.lifetimeEp,
+ currentEp: profile!.currentEp,
+ currentRp: profile!.currentRp + item.rpCost,
+ tier: profile!.tier,
+ );
+ notifyListeners();
+ }
+ rethrow;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Submit a contribution
+ // ---------------------------------------------------------------------------
+ Future submitContribution(Map data) async {
+ await _service.submitContribution(data);
+ }
+}
diff --git a/lib/features/gamification/services/gamification_service.dart b/lib/features/gamification/services/gamification_service.dart
new file mode 100644
index 0000000..19f1c80
--- /dev/null
+++ b/lib/features/gamification/services/gamification_service.dart
@@ -0,0 +1,180 @@
+// lib/features/gamification/services/gamification_service.dart
+//
+// Stub service using the real API contract from TechDocs v2.
+// All methods currently return mock data.
+// TODO: replace each mock block with a real ApiClient call once
+// the backend endpoints are live on uat.eventifyplus.com.
+
+import 'dart:math';
+import '../models/gamification_models.dart';
+
+class GamificationService {
+ // ---------------------------------------------------------------------------
+ // User Gamification Profile
+ // TODO: replace with ApiClient.get(ApiEndpoints.gamificationProfile)
+ // ---------------------------------------------------------------------------
+ Future getProfile() async {
+ await Future.delayed(const Duration(milliseconds: 400));
+ return const UserGamificationProfile(
+ userId: 'mock-user-001',
+ lifetimeEp: 320,
+ currentEp: 70,
+ currentRp: 45,
+ tier: ContributorTier.SILVER,
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Leaderboard
+ // district: 'Overall Kerala' | 'Thiruvananthapuram' | 'Kollam' | ...
+ // timePeriod: 'all_time' | 'this_month'
+ // TODO: replace with ApiClient.get(ApiEndpoints.leaderboard, params: {'district': district, 'period': timePeriod})
+ // ---------------------------------------------------------------------------
+ Future> getLeaderboard({
+ required String district,
+ required String timePeriod,
+ }) async {
+ await Future.delayed(const Duration(milliseconds: 500));
+
+ // Realistic mock names per district
+ final names = [
+ 'Annette Black', 'Jerome Bell', 'Theresa Webb', 'Courtney Henry',
+ 'Cameron Williamson', 'Dianne Russell', 'Wade Warren', 'Albert Flores',
+ 'Kristin Watson', 'Guy Hawkins',
+ ];
+
+ final rng = Random(district.hashCode ^ timePeriod.hashCode);
+ final baseEp = timePeriod == 'this_month' ? 800 : 4500;
+
+ final entries = List.generate(10, (i) {
+ final ep = baseEp - (i * (timePeriod == 'this_month' ? 55 : 280)) + rng.nextInt(30);
+ return LeaderboardEntry(
+ rank: i + 1,
+ username: names[i],
+ lifetimeEp: ep,
+ tier: tierFromEp(ep),
+ eventsCount: 149 - i * 12,
+ isCurrentUser: i == 7, // mock: current user is rank 8
+ );
+ });
+
+ return entries;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Redeem Shop Items
+ // TODO: replace with ApiClient.get(ApiEndpoints.shopItems)
+ // ---------------------------------------------------------------------------
+ Future> getShopItems() async {
+ await Future.delayed(const Duration(milliseconds: 400));
+ return const [
+ ShopItem(
+ id: 'item-001',
+ name: 'Amazon ₹500 Voucher',
+ description: 'Redeem for any purchase on Amazon India.',
+ rpCost: 50,
+ stockQuantity: 20,
+ ),
+ ShopItem(
+ id: 'item-002',
+ name: 'Swiggy ₹200 Voucher',
+ description: 'Free food delivery credit on Swiggy.',
+ rpCost: 20,
+ stockQuantity: 35,
+ ),
+ ShopItem(
+ id: 'item-003',
+ name: 'Eventify Pro — 1 Month',
+ description: 'Premium access to Eventify.Plus features.',
+ rpCost: 30,
+ stockQuantity: 100,
+ ),
+ ShopItem(
+ id: 'item-004',
+ name: 'Zomato ₹150 Voucher',
+ description: 'Discount on your next Zomato order.',
+ rpCost: 15,
+ stockQuantity: 50,
+ ),
+ ShopItem(
+ id: 'item-005',
+ name: 'BookMyShow ₹300 Voucher',
+ description: 'Movie & event ticket credit on BookMyShow.',
+ rpCost: 30,
+ stockQuantity: 15,
+ ),
+ ShopItem(
+ id: 'item-006',
+ name: 'Exclusive Badge',
+ description: 'Rare "Pioneer" badge for your profile.',
+ rpCost: 5,
+ stockQuantity: 0, // out of stock
+ ),
+ ];
+ }
+
+ // ---------------------------------------------------------------------------
+ // Redeem an item
+ // TODO: replace with ApiClient.post(ApiEndpoints.shopRedeem, body: {'item_id': itemId})
+ // ---------------------------------------------------------------------------
+ Future redeemItem(String itemId) async {
+ await Future.delayed(const Duration(milliseconds: 600));
+ // Generate a fake voucher code
+ final code = 'EVT-${itemId.toUpperCase().replaceAll('-', '').substring(0, 4)}-${Random().nextInt(9000) + 1000}';
+ return RedemptionRecord(
+ id: 'redemption-${DateTime.now().millisecondsSinceEpoch}',
+ itemId: itemId,
+ rpSpent: 0, // provider will look up cost
+ voucherCode: code,
+ timestamp: DateTime.now(),
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Submit Contribution
+ // TODO: replace with ApiClient.post(ApiEndpoints.contributeSubmit, body: data)
+ // ---------------------------------------------------------------------------
+ Future submitContribution(Map data) async {
+ await Future.delayed(const Duration(milliseconds: 800));
+ // Mock always succeeds
+ }
+
+ // ---------------------------------------------------------------------------
+ // Achievements
+ // ---------------------------------------------------------------------------
+ Future> getAchievements() async {
+ await Future.delayed(const Duration(milliseconds: 300));
+ return const [
+ AchievementBadge(
+ id: 'badge-01', title: 'First Submission',
+ description: 'Submitted your first event.',
+ iconName: 'edit', isUnlocked: true, progress: 1.0,
+ ),
+ AchievementBadge(
+ id: 'badge-02', title: 'Silver Streak',
+ description: 'Reached Silver tier.',
+ iconName: 'star', isUnlocked: true, progress: 1.0,
+ ),
+ AchievementBadge(
+ id: 'badge-03', title: 'Gold Rush',
+ description: 'Reach Gold tier (500 EP).',
+ iconName: 'emoji_events', isUnlocked: false, progress: 0.64,
+ ),
+ AchievementBadge(
+ id: 'badge-04', title: 'Top 10',
+ description: 'Appear in the district leaderboard top 10.',
+ iconName: 'leaderboard', isUnlocked: false, progress: 0.5,
+ ),
+ AchievementBadge(
+ id: 'badge-05', title: 'Image Pro',
+ description: 'Submit 10 events with 3+ images.',
+ iconName: 'photo_library', isUnlocked: false, progress: 0.3,
+ ),
+ AchievementBadge(
+ id: 'badge-06', title: 'Pioneer',
+ description: 'One of the first 100 contributors.',
+ iconName: 'verified', isUnlocked: true, progress: 1.0,
+ ),
+ ];
+ }
+}
diff --git a/lib/screens/contribute_screen.dart b/lib/screens/contribute_screen.dart
index 96e4421..0717525 100644
--- a/lib/screens/contribute_screen.dart
+++ b/lib/screens/contribute_screen.dart
@@ -1,11 +1,56 @@
// lib/screens/contribute_screen.dart
-import 'dart:io';
-import '../core/app_decoration.dart';
+// Contributor Module v2 — matches PRD v3 / TechDocs v2 / Web version.
+// 4 tabs: Contribute · Leaderboard · Achievements · Shop
+import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
+import 'package:provider/provider.dart';
+import 'package:share_plus/share_plus.dart';
+import '../core/app_decoration.dart';
+import '../features/gamification/models/gamification_models.dart';
+import '../features/gamification/providers/gamification_provider.dart';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tier colour map
+// ─────────────────────────────────────────────────────────────────────────────
+const _tierColors = {
+ ContributorTier.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 = {
+ 'edit': Icons.edit_outlined,
+ 'star': Icons.star_outline,
+ 'emoji_events': Icons.emoji_events_outlined,
+ 'leaderboard': Icons.leaderboard_outlined,
+ 'photo_library': Icons.photo_library_outlined,
+ 'verified': Icons.verified_outlined,
+};
+
+// District list for the contribution form
+const _districts = [
+ 'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
+ 'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
+ 'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
+ 'Other',
+];
+
+const _categories = [
+ 'Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community',
+ 'Dance', 'Film', 'Business', 'Health', 'Education', 'Other',
+];
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ContributeScreen
+// ─────────────────────────────────────────────────────────────────────────────
class ContributeScreen extends StatefulWidget {
const ContributeScreen({Key? key}) : super(key: key);
@@ -13,36 +58,39 @@ class ContributeScreen extends StatefulWidget {
State createState() => _ContributeScreenState();
}
-class _ContributeScreenState extends State with SingleTickerProviderStateMixin {
- // Primary accent used for buttons / active tab (kept as a single constant)
+class _ContributeScreenState extends State
+ with SingleTickerProviderStateMixin {
static const Color _primary = Color(0xFF0B63D6);
-
- // single corner radius to use everywhere
static const double _cornerRadius = 18.0;
- // Form controllers
- final TextEditingController _titleCtl = TextEditingController();
- final TextEditingController _locationCtl = TextEditingController();
- final TextEditingController _organizerCtl = TextEditingController();
- final TextEditingController _descriptionCtl = TextEditingController();
- DateTime? _selectedDate;
- String _selectedCategory = 'Music';
-
- // Image pickers
- final ImagePicker _picker = ImagePicker();
- XFile? _coverImageFile;
- XFile? _thumbImageFile;
-
- bool _submitting = false;
-
- // Tab state: 0 = Contribute, 1 = Leaderboard, 2 = Achievements
int _activeTab = 0;
- // Example progress value (0..1)
- double _progress = 0.45;
+ // ── Contribution form state ──────────────────────────────────────────────
+ final _formKey = GlobalKey();
+ final _titleCtl = TextEditingController();
+ final _locationCtl = TextEditingController();
+ final _organizerCtl = TextEditingController();
+ final _descriptionCtl = TextEditingController();
+ final _ticketPriceCtl = TextEditingController();
+ final _contactCtl = TextEditingController();
+ final _websiteCtl = TextEditingController();
- // A few category options
- final List _categories = ['Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community'];
+ DateTime? _selectedDate;
+ TimeOfDay? _selectedTime;
+ String _selectedCategory = _categories.first;
+ String _selectedDistrict = _districts.first;
+ List _images = [];
+ bool _submitting = false;
+
+ final _picker = ImagePicker();
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ context.read().loadAll();
+ });
+ }
@override
void dispose() {
@@ -50,178 +98,468 @@ class _ContributeScreenState extends State with SingleTickerPr
_locationCtl.dispose();
_organizerCtl.dispose();
_descriptionCtl.dispose();
+ _ticketPriceCtl.dispose();
+ _contactCtl.dispose();
+ _websiteCtl.dispose();
super.dispose();
}
- Future _pickCoverImage() async {
- try {
- final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
- if (picked != null) setState(() => _coverImageFile = picked);
- } catch (e) {
- if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick image: $e')));
- }
- }
+ // ─────────────────────────────────────────────────────────────────────────
+ // Build
+ // ─────────────────────────────────────────────────────────────────────────
+ // Desktop sub-nav state: 0=Submit Event, 1=My Events, 2=Reward Shop
+ int _desktopSubNav = 0;
- Future _pickThumbnailImage() async {
- try {
- final XFile? picked = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
- if (picked != null) setState(() => _thumbImageFile = picked);
- } catch (e) {
- if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick image: $e')));
- }
- }
-
- Future _pickDate() async {
- final now = DateTime.now();
- final picked = await showDatePicker(
- context: context,
- initialDate: _selectedDate ?? now,
- firstDate: DateTime(now.year - 2),
- lastDate: DateTime(now.year + 3),
- builder: (context, child) {
- return Theme(
- data: Theme.of(context).copyWith(colorScheme: ColorScheme.light(primary: _primary)),
- child: child!,
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = MediaQuery.of(context).size.width >= 820;
+ return Consumer(
+ builder: (context, provider, _) {
+ return Scaffold(
+ backgroundColor: const Color(0xFFF5F7FB),
+ body: isDesktop
+ ? _buildDesktopLayout(context, provider)
+ : Column(
+ children: [
+ _buildHeader(context, provider),
+ Expanded(child: _buildTabBody(context, provider)),
+ ],
+ ),
);
},
);
- if (picked != null) setState(() => _selectedDate = picked);
}
- Future _submit() async {
- if (_titleCtl.text.trim().isEmpty) {
- ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter an event title')));
- return;
- }
+ // ═══════════════════════════════════════════════════════════════════════════
+ // DESKTOP LAYOUT — matches web at mvnew.eventifyplus.com/contribute
+ // ═══════════════════════════════════════════════════════════════════════════
- setState(() => _submitting = true);
- // simulate work
- await Future.delayed(const Duration(milliseconds: 800));
+ static const _desktopTabs = ['Contribute', 'Leaderboard', 'Achievements'];
+ static const _desktopTabIcons = [Icons.edit_note, null, null];
- setState(() => _submitting = false);
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Submitted for verification (demo)')));
- _clearForm();
- }
- }
+ Widget _buildDesktopLayout(BuildContext context, GamificationProvider provider) {
+ final profile = provider.profile;
+ final tier = profile?.tier ?? ContributorTier.BRONZE;
+ final lifetimeEp = profile?.lifetimeEp ?? 0;
+ final currentEp = profile?.currentEp ?? 0;
+ final currentRp = profile?.currentRp ?? 0;
- void _clearForm() {
- _titleCtl.clear();
- _locationCtl.clear();
- _organizerCtl.clear();
- _descriptionCtl.clear();
- _selectedDate = null;
- _selectedCategory = _categories.first;
- _coverImageFile = null;
- _thumbImageFile = null;
- setState(() {});
- }
+ // Calculate next tier threshold
+ const thresholds = [0, 100, 500, 1500, 5000];
+ final tierIdx = tier.index;
+ final nextThresh = tierIdx < 4 ? thresholds[tierIdx + 1] : thresholds[4];
+ final prevThresh = thresholds[tierIdx];
+ final progress = tierIdx >= 4 ? 1.0 : (lifetimeEp - prevThresh) / (nextThresh - prevThresh);
- // ---------- UI Builders ----------
+ return SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24),
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 900),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // Title
+ const Text('Contributor Dashboard',
+ style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: Color(0xFF111827))),
+ const SizedBox(height: 6),
+ const Text('Track your impact, earn rewards, and climb the ranks!',
+ style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
+ const SizedBox(height: 24),
- Widget _buildHeader(BuildContext context) {
- final theme = Theme.of(context);
- // header uses AppDecoration.blueGradient for background (project-specific)
- return Container(
- width: double.infinity,
- padding: const EdgeInsets.fromLTRB(20, 32, 20, 24),
- decoration: AppDecoration.blueGradient.copyWith(
- borderRadius: BorderRadius.only(
- bottomLeft: Radius.circular(_cornerRadius),
- bottomRight: Radius.circular(_cornerRadius),
- ),
- // subtle shadow only
- boxShadow: [
- BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 4)),
- ],
- ),
- child: SafeArea(
- bottom: false,
- child: Column(
- children: [
- // increased spacing to create a breathable layout
- const SizedBox(height: 6),
-
- // Title & subtitle (centered) — smaller title weight, clearer hierarchy
- Text(
- 'Contributor Dashboard',
- textAlign: TextAlign.center,
- style: theme.textTheme.titleLarge?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.w700,
- fontSize: 20,
- ),
- ),
- const SizedBox(height: 12), // more space between title & subtitle
- Text(
- 'Track your impact, earn rewards, and climb the ranks!',
- textAlign: TextAlign.center,
- style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white.withOpacity(0.92), fontSize: 13, height: 1.35),
- ),
-
- const SizedBox(height: 20), // more space before tabs
-
- // Pill-style segmented tabs (animated active) — slimmer / minimal
- _buildSegmentedTabs(context),
-
- const SizedBox(height: 18), // comfortable spacing before contributor card
-
- // Contributor level card — lighter, soft border, thinner progress
- Container(
- width: double.infinity,
- padding: const EdgeInsets.all(14),
- margin: const EdgeInsets.symmetric(horizontal: 4),
- decoration: BoxDecoration(
- color: Colors.white.withOpacity(0.08), // slightly lighter than before
- borderRadius: BorderRadius.circular(_cornerRadius - 2),
- border: Border.all(color: Colors.white.withOpacity(0.09)), // soft border
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('Contributor Level',
- style: theme.textTheme.titleSmall?.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
- const SizedBox(height: 8),
- Text('Start earning rewards by contributing!', style: theme.textTheme.bodySmall?.copyWith(color: Colors.white70)),
- const SizedBox(height: 12),
- Row(
- children: [
- Expanded(
- // animated progress using TweenAnimationBuilder
- child: TweenAnimationBuilder(
- tween: Tween(begin: 0.0, end: _progress),
- duration: const Duration(milliseconds: 700),
- builder: (context, value, _) => ClipRRect(
- borderRadius: BorderRadius.circular(6),
- child: LinearProgressIndicator(
- value: value,
- minHeight: 6, // thinner progress bar
- valueColor: const AlwaysStoppedAnimation(Colors.white),
- backgroundColor: Colors.white24,
- ),
+ // ── Desktop Tab bar (3 tabs in blue pill) ──
+ Container(
+ decoration: BoxDecoration(
+ color: _primary,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ padding: const EdgeInsets.all(5),
+ child: Row(
+ children: List.generate(_desktopTabs.length, (i) {
+ final isActive = _activeTab == i;
+ return Expanded(
+ child: GestureDetector(
+ onTap: () => setState(() => _activeTab = i),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ decoration: BoxDecoration(
+ color: isActive ? Colors.white : Colors.transparent,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (i == 0) ...[
+ Icon(Icons.edit_note, size: 18,
+ color: isActive ? _primary : Colors.white70),
+ const SizedBox(width: 6),
+ ],
+ Text(
+ _desktopTabs[i],
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: isActive ? _primary : Colors.white,
+ ),
+ ),
+ ],
),
),
),
- const SizedBox(width: 12),
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
- decoration: BoxDecoration(
- color: Colors.white.withOpacity(0.12),
- borderRadius: BorderRadius.circular(12),
+ );
+ }),
+ ),
+ ),
+ const SizedBox(height: 20),
+
+ // ── Contributor Level card ──
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [Color(0xFF0F45CF), Color(0xFF3B82F6)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Contributor Level',
+ style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)),
+ const SizedBox(height: 4),
+ const Text('Start earning rewards by contributing!',
+ style: TextStyle(color: Colors.white70, fontSize: 13)),
+ ],
),
- child: Text('Explorer', style: theme.textTheme.bodySmall?.copyWith(color: Colors.white, fontWeight: FontWeight.w600)),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.2),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(tierLabel(tier),
+ style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('$lifetimeEp pts',
+ style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
+ if (tierIdx < 4)
+ Text('Next: ${tierLabel(ContributorTier.values[tierIdx + 1])} (${thresholds[tierIdx + 1]} pts)',
+ style: const TextStyle(color: Colors.white70, fontSize: 13)),
+ ],
+ ),
+ const SizedBox(height: 8),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(6),
+ child: LinearProgressIndicator(
+ value: progress.clamp(0.0, 1.0),
+ minHeight: 8,
+ backgroundColor: Colors.white.withOpacity(0.2),
+ valueColor: const AlwaysStoppedAnimation(Colors.white),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 20),
+
+ // ── Desktop tab body ──
+ _buildDesktopTabBody(context, provider),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDesktopTabBody(BuildContext context, GamificationProvider provider) {
+ switch (_activeTab) {
+ case 0:
+ return _buildDesktopContributeTab(context, provider);
+ case 1:
+ return _buildDesktopLeaderboardTab(context, provider);
+ case 2:
+ return _buildDesktopAchievementsTab(context, provider);
+ default:
+ return const SizedBox();
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // DESKTOP — Contribute Tab
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ Widget _buildDesktopContributeTab(BuildContext context, GamificationProvider provider) {
+ final profile = provider.profile;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // ── Sub-navigation buttons ──
+ Row(
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: () => setState(() => _desktopSubNav = 1),
+ icon: const Icon(Icons.list_alt, size: 18),
+ label: const Text('My Events'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: _desktopSubNav == 1 ? Colors.white : const Color(0xFF374151),
+ backgroundColor: _desktopSubNav == 1 ? _primary : Colors.white,
+ side: BorderSide(color: _desktopSubNav == 1 ? _primary : const Color(0xFFD1D5DB)),
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: () => setState(() => _desktopSubNav = 0),
+ icon: const Icon(Icons.add, size: 18),
+ label: const Text('Submit Event'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: _desktopSubNav == 0 ? Colors.white : const Color(0xFF374151),
+ backgroundColor: _desktopSubNav == 0 ? _primary : Colors.white,
+ side: BorderSide(color: _desktopSubNav == 0 ? _primary : const Color(0xFFD1D5DB)),
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: ElevatedButton.icon(
+ onPressed: () => setState(() => _desktopSubNav = 2),
+ icon: const Icon(Icons.shopping_bag_outlined, size: 18),
+ label: const Text('Reward Shop'),
+ style: ElevatedButton.styleFrom(
+ foregroundColor: _desktopSubNav == 2 ? Colors.white : Colors.white,
+ backgroundColor: _desktopSubNav == 2 ? const Color(0xFF1D4ED8) : _primary,
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+
+ // ── Sub-nav content ──
+ if (_desktopSubNav == 0) _buildDesktopSubmitForm(context, provider),
+ if (_desktopSubNav == 1) _buildDesktopMyEvents(),
+ if (_desktopSubNav == 2) _buildDesktopRewardShop(provider),
+
+ const SizedBox(height: 24),
+
+ // ── Tier progress bar with milestones ──
+ _buildDesktopTierBar(profile),
+ ],
+ );
+ }
+
+ Widget _buildDesktopSubmitForm(BuildContext context, GamificationProvider provider) {
+ return Container(
+ padding: const EdgeInsets.all(28),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFE5E7EB)),
+ ),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Row 1: Event Title + Category
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ flex: 3,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Event Title', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
+ const SizedBox(height: 6),
+ TextFormField(
+ controller: _titleCtl,
+ decoration: InputDecoration(
+ hintText: 'e.g. Local Food Festival',
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFD1D5DB))),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
+ ),
+ validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
),
],
),
- const SizedBox(height: 8),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ ),
+ const SizedBox(width: 20),
+ Expanded(
+ flex: 2,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Text('30 pts', style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white, fontWeight: FontWeight.w600)),
- Text('Next: Enthusiast (50 pts)', style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70)),
+ const Text('Category', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
+ const SizedBox(height: 6),
+ DropdownButtonFormField(
+ 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'),
),
),
],
@@ -230,33 +568,818 @@ class _ContributeScreenState extends State with SingleTickerPr
);
}
- /// Bouncy spring curve matching web CSS: cubic-bezier(0.37, 1.95, 0.66, 0.56)
+ Widget _buildDesktopImageUpload(String label, IconData icon) {
+ return GestureDetector(
+ onTap: () async {
+ final picked = await _picker.pickImage(source: ImageSource.gallery);
+ if (picked != null) setState(() => _images.add(picked));
+ },
+ child: Container(
+ height: 150,
+ decoration: BoxDecoration(
+ border: Border.all(color: const Color(0xFFD1D5DB), style: BorderStyle.solid),
+ borderRadius: BorderRadius.circular(12),
+ color: const Color(0xFFFAFBFC),
+ ),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 36, color: const Color(0xFF9CA3AF)),
+ const SizedBox(height: 8),
+ Text(label, style: const TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDesktopMyEvents() {
+ return Container(
+ padding: const EdgeInsets.all(40),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFE5E7EB)),
+ ),
+ child: Center(
+ child: Column(
+ children: [
+ Icon(Icons.event_note, size: 48, color: const Color(0xFF9CA3AF)),
+ const SizedBox(height: 12),
+ const Text('No submitted events yet', style: TextStyle(color: Color(0xFF6B7280), fontSize: 15)),
+ const SizedBox(height: 4),
+ const Text('Events you submit will appear here.', style: TextStyle(color: Color(0xFF9CA3AF), fontSize: 13)),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDesktopRewardShop(GamificationProvider provider) {
+ final profile = provider.profile;
+ final currentRp = profile?.currentRp ?? 0;
+ final items = provider.shopItems;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Header
+ Container(
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFE5E7EB)),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.shopping_bag_outlined, color: Color(0xFF0B63D6), size: 24),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: const [
+ Text('Reward Shop', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18)),
+ SizedBox(height: 2),
+ Text('Spend your hard-earned RP on exclusive vouchers and perks.',
+ style: TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
+ ],
+ ),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ color: const Color(0xFFFEF3C7),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: const Color(0xFFF59E0B)),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text('YOUR BALANCE ', style: TextStyle(color: Color(0xFF92400E), fontSize: 11, fontWeight: FontWeight.w600)),
+ Text('$currentRp RP', style: const TextStyle(color: Color(0xFFDC2626), fontSize: 16, fontWeight: FontWeight.w800)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ // Items grid or empty
+ if (items.isEmpty)
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(60),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFE5E7EB), style: BorderStyle.solid),
+ ),
+ child: Column(
+ children: [
+ Icon(Icons.shopping_bag_outlined, size: 48, color: const Color(0xFFD1D5DB)),
+ const SizedBox(height: 12),
+ const Text('No items available', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: Color(0xFF374151))),
+ const SizedBox(height: 4),
+ const Text('Check back soon for new rewards!', style: TextStyle(color: Color(0xFF9CA3AF), fontSize: 13)),
+ ],
+ ),
+ )
+ else
+ GridView.count(
+ crossAxisCount: 3,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ mainAxisSpacing: 12,
+ crossAxisSpacing: 12,
+ childAspectRatio: 1.3,
+ children: items.map((item) => _buildDesktopShopCard(item, currentRp, provider)).toList(),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildDesktopShopCard(ShopItem item, int currentRp, GamificationProvider provider) {
+ final canRedeem = currentRp >= item.rpCost && item.stockQuantity > 0;
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: const Color(0xFFE5E7EB)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(item.name, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
+ const SizedBox(height: 4),
+ Text(item.description, style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis),
+ const Spacer(),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('${item.rpCost} RP', style: const TextStyle(color: Color(0xFFDC2626), fontWeight: FontWeight.w700)),
+ ElevatedButton(
+ onPressed: canRedeem ? () async {
+ final code = await provider.redeemItem(item.id);
+ if (mounted) {
+ showDialog(context: context, builder: (_) => AlertDialog(
+ title: const Text('Redeemed!'),
+ content: Text('Voucher code: $code'),
+ actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
+ ));
+ }
+ } : null,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: _primary,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
+ ),
+ child: const Text('Redeem'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildDesktopTierBar(UserGamificationProfile? profile) {
+ final lifetimeEp = profile?.lifetimeEp ?? 0;
+ final currentEp = profile?.currentEp ?? 0;
+ final currentRp = profile?.currentRp ?? 0;
+ final tier = profile?.tier ?? ContributorTier.BRONZE;
+
+ return Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFE5E7EB)),
+ ),
+ child: Column(
+ children: [
+ // Top row: tier chip + stats + share
+ Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
+ decoration: BoxDecoration(
+ color: _primary,
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.diamond_outlined, size: 16, color: Colors.white),
+ const SizedBox(width: 6),
+ Text(tierLabel(tier).toUpperCase(),
+ style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12, letterSpacing: 0.5)),
+ ],
+ ),
+ ),
+ const SizedBox(width: 16),
+ Icon(Icons.bolt, size: 18, color: const Color(0xFF6B7280)),
+ const SizedBox(width: 4),
+ Text('$currentEp', style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
+ const Text(' Liquid EP', style: TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
+ const SizedBox(width: 20),
+ Icon(Icons.card_giftcard, size: 18, color: const Color(0xFF6B7280)),
+ const SizedBox(width: 4),
+ Text('$currentRp', style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
+ const Text(' RP', style: TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
+ const Spacer(),
+ OutlinedButton.icon(
+ onPressed: () {
+ Share.share(
+ 'I\'m a ${tierLabel(tier)} contributor on @EventifyPlus with $lifetimeEp EP! 🏆 '
+ 'Discover & contribute to events near you at eventifyplus.com',
+ subject: 'My Eventify.Plus Contributor Rank',
+ );
+ },
+ icon: const Icon(Icons.share_outlined, size: 16),
+ label: const Text('Share Rank'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: _primary,
+ side: const BorderSide(color: Color(0xFFD1D5DB)),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ // Tier milestones
+ Row(
+ children: [
+ _tierMilestone('Bronze', '0 EP', tier.index >= 0),
+ Expanded(child: Container(height: 2, color: tier.index >= 1 ? _primary : const Color(0xFFE5E7EB))),
+ _tierMilestone('Silver', '100 EP', tier.index >= 1),
+ Expanded(child: Container(height: 2, color: tier.index >= 2 ? _primary : const Color(0xFFE5E7EB))),
+ _tierMilestone('Gold', '500 EP', tier.index >= 2),
+ Expanded(child: Container(height: 2, color: tier.index >= 3 ? _primary : const Color(0xFFE5E7EB))),
+ _tierMilestone('Platinum', '1.5K EP', tier.index >= 3),
+ Expanded(child: Container(height: 2, color: tier.index >= 4 ? _primary : const Color(0xFFE5E7EB))),
+ _tierMilestone('Diamond', '5K EP', tier.index >= 4),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _tierMilestone(String label, String ep, bool active) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(label, style: TextStyle(
+ fontSize: 12, fontWeight: active ? FontWeight.w700 : FontWeight.w500,
+ color: active ? const Color(0xFF111827) : const Color(0xFF9CA3AF))),
+ Text(ep, style: TextStyle(
+ fontSize: 10, color: active ? const Color(0xFF6B7280) : const Color(0xFFD1D5DB))),
+ ],
+ );
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // DESKTOP — Leaderboard Tab
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ Widget _buildDesktopLeaderboardTab(BuildContext context, GamificationProvider provider) {
+ if (provider.isLoading && provider.leaderboard.isEmpty) {
+ return const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator()));
+ }
+
+ final entries = provider.leaderboard;
+ final matching = entries.where((e) => e.isCurrentUser).toList();
+ final myEntry = matching.isNotEmpty ? matching.first : null;
+
+ return Column(
+ children: [
+ // Filters
+ _buildDesktopLeaderboardFilters(provider),
+ const SizedBox(height: 16),
+
+ // Podium
+ if (entries.length >= 3) _buildDesktopPodium(entries.take(3).toList()),
+ const SizedBox(height: 16),
+
+ // Rank table
+ Container(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ children: [
+ // Headers
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ decoration: const BoxDecoration(
+ border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))),
+ ),
+ child: Row(
+ children: const [
+ SizedBox(width: 50, child: Text('RANK', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
+ SizedBox(width: 50),
+ Expanded(child: Text('USER', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
+ SizedBox(width: 100, child: Text('POINTS', textAlign: TextAlign.right, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
+ SizedBox(width: 20),
+ SizedBox(width: 80, child: Text('LEVEL', textAlign: TextAlign.center, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
+ SizedBox(width: 20),
+ SizedBox(width: 100, child: Text('EVENTS ADDED', textAlign: TextAlign.right, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))),
+ ],
+ ),
+ ),
+ // Rows
+ ...entries.skip(3).map((e) => _buildDesktopRankRow(e)).toList(),
+ ],
+ ),
+ ),
+
+ // My rank
+ if (myEntry != null) ...[
+ const SizedBox(height: 16),
+ _buildMyRankCard(myEntry),
+ ],
+ ],
+ );
+ }
+
+ Widget _buildDesktopLeaderboardFilters(GamificationProvider provider) {
+ return Column(
+ children: [
+ // Time toggle — right aligned
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ _timePill('All Time', 'all_time', provider),
+ const SizedBox(width: 6),
+ _timePill('This Month', 'this_month', provider),
+ ],
+ ),
+ const SizedBox(height: 10),
+ // District pills
+ SizedBox(
+ height: 42,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: _lbDistricts.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
+ itemBuilder: (_, i) {
+ final d = _lbDistricts[i];
+ final isActive = provider.leaderboardDistrict == d;
+ return GestureDetector(
+ onTap: () => provider.setDistrict(d),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
+ decoration: BoxDecoration(
+ color: isActive ? _primary : Colors.white,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: isActive ? _primary : const Color(0xFFD1D5DB)),
+ ),
+ child: Text(d, style: TextStyle(
+ color: isActive ? Colors.white : const Color(0xFF374151),
+ fontWeight: FontWeight.w500, fontSize: 13,
+ )),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }
+
+ // Reuses _lbDistricts defined in the mobile leaderboard section below
+
+ Widget _buildDesktopPodium(List top3) {
+ final first = top3[0];
+ final second = top3[1];
+ final third = top3[2];
+
+ return SizedBox(
+ height: 260,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ // #2 Silver
+ _desktopPodiumUser(second, 2, 120, const Color(0xFFBDBDBD)),
+ const SizedBox(width: 8),
+ // #1 Gold
+ _desktopPodiumUser(first, 1, 160, const Color(0xFFF59E0B)),
+ const SizedBox(width: 8),
+ // #3 Bronze
+ _desktopPodiumUser(third, 3, 100, const Color(0xFF92400E)),
+ ],
+ ),
+ );
+ }
+
+ Widget _desktopPodiumUser(LeaderboardEntry entry, int rank, double pillarHeight, Color pillarColor) {
+ final rankColors = {1: const Color(0xFFF59E0B), 2: const Color(0xFF6B7280), 3: const Color(0xFF92400E)};
+
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ // Avatar with rank badge
+ Stack(
+ clipBehavior: Clip.none,
+ children: [
+ CircleAvatar(
+ radius: rank == 1 ? 36 : 28,
+ backgroundColor: pillarColor.withOpacity(0.2),
+ child: Text(entry.username[0], style: TextStyle(fontSize: rank == 1 ? 24 : 18, fontWeight: FontWeight.w700, color: pillarColor)),
+ ),
+ Positioned(
+ bottom: -4, right: -4,
+ child: Container(
+ width: 22, height: 22,
+ decoration: BoxDecoration(
+ color: rankColors[rank],
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.white, width: 2),
+ ),
+ child: Center(child: Text('$rank', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700))),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(entry.username, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
+ Text(_fmtPts(entry.lifetimeEp),
+ style: TextStyle(color: rankColors[rank], fontWeight: FontWeight.w600, fontSize: 12)),
+ const SizedBox(height: 6),
+ // Pillar
+ Container(
+ width: 140,
+ height: pillarHeight,
+ decoration: BoxDecoration(
+ color: pillarColor.withOpacity(rank == 1 ? 0.85 : 0.6),
+ borderRadius: const BorderRadius.only(topLeft: Radius.circular(8), topRight: Radius.circular(8)),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildDesktopRankRow(LeaderboardEntry entry) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
+ decoration: BoxDecoration(
+ color: entry.isCurrentUser ? const Color(0xFFEFF6FF) : Colors.white,
+ border: const Border(bottom: BorderSide(color: Color(0xFFF3F4F6))),
+ ),
+ child: Row(
+ children: [
+ SizedBox(width: 50, child: Text('${entry.rank}',
+ style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: Color(0xFF374151)))),
+ CircleAvatar(
+ radius: 18,
+ backgroundColor: const Color(0xFFE5E7EB),
+ child: Text(entry.username[0], style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
+ ),
+ const SizedBox(width: 12),
+ Expanded(child: Text(entry.username, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14))),
+ SizedBox(
+ width: 100,
+ child: Text(_fmtPts(entry.lifetimeEp),
+ textAlign: TextAlign.right,
+ style: const TextStyle(color: Color(0xFF16A34A), fontWeight: FontWeight.w700, fontSize: 14)),
+ ),
+ const SizedBox(width: 20),
+ SizedBox(
+ width: 80,
+ child: Center(
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: _primary,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(tierLabel(entry.tier),
+ style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)),
+ ),
+ ),
+ ),
+ const SizedBox(width: 20),
+ SizedBox(
+ width: 100,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ const Icon(Icons.calendar_today, size: 14, color: Color(0xFF9CA3AF)),
+ const SizedBox(width: 6),
+ Text('${entry.eventsCount}', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // DESKTOP — Achievements Tab
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ Widget _buildDesktopAchievementsTab(BuildContext context, GamificationProvider provider) {
+ final badges = provider.achievements;
+ if (provider.isLoading && badges.isEmpty) {
+ return const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator()));
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Your Badges', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 20, color: Color(0xFF111827))),
+ const SizedBox(height: 16),
+ GridView.count(
+ crossAxisCount: 3,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ mainAxisSpacing: 16,
+ crossAxisSpacing: 16,
+ childAspectRatio: 1.6,
+ children: badges.map((badge) => _buildDesktopBadgeCard(badge)).toList(),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildDesktopBadgeCard(AchievementBadge badge) {
+ final icon = _badgeIcons[badge.iconName] ?? Icons.emoji_events_outlined;
+ final isUnlocked = badge.isUnlocked;
+
+ // Badge icon colors
+ final iconColors = {
+ 'edit': const Color(0xFF3B82F6),
+ 'star': const Color(0xFFF59E0B),
+ 'emoji_events': const Color(0xFFF97316),
+ 'leaderboard': const Color(0xFF8B5CF6),
+ 'photo_library': const Color(0xFF6B7280),
+ 'verified': const Color(0xFF10B981),
+ };
+ final bgColors = {
+ 'edit': const Color(0xFFDBEAFE),
+ 'star': const Color(0xFFFEF3C7),
+ 'emoji_events': const Color(0xFFFED7AA),
+ 'leaderboard': const Color(0xFFEDE9FE),
+ 'photo_library': const Color(0xFFF3F4F6),
+ 'verified': const Color(0xFFD1FAE5),
+ };
+
+ final iconColor = isUnlocked ? (iconColors[badge.iconName] ?? const Color(0xFF6B7280)) : const Color(0xFF9CA3AF);
+ final bgColor = isUnlocked ? (bgColors[badge.iconName] ?? const Color(0xFFF3F4F6)) : const Color(0xFFF3F4F6);
+
+ return Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: const Color(0xFFE5E7EB)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ width: 42, height: 42,
+ decoration: BoxDecoration(
+ color: bgColor,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(icon, color: iconColor, size: 22),
+ ),
+ if (!isUnlocked) ...[
+ const Spacer(),
+ const Icon(Icons.lock_outline, size: 16, color: Color(0xFF9CA3AF)),
+ ],
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(badge.title, style: TextStyle(
+ fontWeight: FontWeight.w700, fontSize: 14,
+ color: isUnlocked ? const Color(0xFF111827) : const Color(0xFF9CA3AF))),
+ const SizedBox(height: 2),
+ Text(badge.description, style: TextStyle(
+ fontSize: 12, color: isUnlocked ? const Color(0xFF6B7280) : const Color(0xFFD1D5DB)),
+ maxLines: 2, overflow: TextOverflow.ellipsis),
+ if (badge.progress < 1.0 && badge.progress > 0) ...[
+ const Spacer(),
+ Row(
+ children: [
+ Expanded(
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(4),
+ child: LinearProgressIndicator(
+ value: badge.progress,
+ minHeight: 6,
+ backgroundColor: const Color(0xFFE5E7EB),
+ valueColor: const AlwaysStoppedAnimation(Color(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(
+ 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(tierColor),
+ backgroundColor: Colors.white24,
+ ),
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ nextThresh != null
+ ? '${nextThresh - lifetimeEp} EP to ${tierLabel(ContributorTier.values[tier.index + 1])}'
+ : '🎉 Maximum tier reached!',
+ style: theme.textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _statChip(String value, String label, Color bg) {
+ return Expanded(
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
+ decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)),
+ child: Column(
+ children: [
+ Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
+ Text(label, style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 10)),
+ ],
+ ),
+ ),
+ );
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Segmented Tabs (4 tabs)
+ // ─────────────────────────────────────────────────────────────────────────
static const Curve _bouncyCurve = Cubic(0.37, 1.95, 0.66, 0.56);
- /// Tab icons for each tab
static const List _tabIcons = [
Icons.edit_outlined,
Icons.emoji_events_outlined,
Icons.workspace_premium_outlined,
+ Icons.storefront_outlined,
];
Widget _buildSegmentedTabs(BuildContext context) {
- final tabs = ['Contribute', 'Leaderboard', 'Achievements'];
+ const tabs = ['Contribute', 'Leaderboard', 'Achievements', 'Shop'];
return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 10),
+ padding: const EdgeInsets.symmetric(horizontal: 2),
child: LayoutBuilder(
builder: (context, constraints) {
- final containerWidth = constraints.maxWidth;
- // 6px padding on each side of the container
- const double containerPadding = 6.0;
- final innerWidth = containerWidth - (containerPadding * 2);
- final tabWidth = innerWidth / tabs.length;
+ const double padding = 5.0;
+ final tabWidth = (constraints.maxWidth - padding * 2) / tabs.length;
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
- height: 57,
+ height: 52,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
@@ -264,85 +1387,54 @@ class _ContributeScreenState extends State with SingleTickerPr
),
child: Stack(
children: [
- // ── Sliding glider ──
+ // Sliding glider
AnimatedPositioned(
- duration: const Duration(milliseconds: 500),
+ duration: const Duration(milliseconds: 450),
curve: _bouncyCurve,
- left: containerPadding + (_activeTab * tabWidth),
- top: containerPadding,
+ left: padding + _activeTab * tabWidth,
+ top: padding,
width: tabWidth,
- height: 57 - (containerPadding * 2),
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 400),
- curve: Curves.easeInOut,
+ 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: 15,
- offset: const Offset(0, 4),
- ),
- BoxShadow(
- color: Colors.black.withOpacity(0.08),
- blurRadius: 3,
- offset: const Offset(0, 1),
- ),
+ BoxShadow(color: Colors.black.withOpacity(0.10), blurRadius: 12, offset: const Offset(0, 3)),
],
),
),
),
-
- // ── Tab labels ──
+ // Labels
Padding(
- padding: const EdgeInsets.all(containerPadding),
+ padding: const EdgeInsets.all(padding),
child: Row(
children: List.generate(tabs.length, (i) {
- final active = i == _activeTab;
- return Expanded(
- child: GestureDetector(
- onTap: () => setState(() => _activeTab = i),
- behavior: HitTestBehavior.opaque,
- child: SizedBox(
- height: double.infinity,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- // Animated icon: only shows for active tab
- AnimatedSize(
- duration: const Duration(milliseconds: 300),
- curve: Curves.easeInOut,
- child: active
- ? Padding(
- padding: const EdgeInsets.only(right: 6),
- child: Icon(
- _tabIcons[i],
- size: 15,
- color: _primary,
- ),
- )
- : const SizedBox.shrink(),
+ 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),
),
- Flexible(
- child: AnimatedDefaultTextStyle(
- duration: const Duration(milliseconds: 300),
- curve: Curves.easeInOut,
- style: TextStyle(
- color: active ? _primary : Colors.white.withOpacity(0.7),
- fontWeight: FontWeight.w600,
- fontSize: 14,
- fontFamily: Theme.of(context).textTheme.bodyMedium?.fontFamily,
- ),
- child: Text(
- tabs[i],
- textAlign: TextAlign.center,
- overflow: TextOverflow.ellipsis,
- ),
- ),
- ),
- ],
- ),
+ ),
+ ],
),
),
);
@@ -358,336 +1450,548 @@ class _ContributeScreenState extends State with SingleTickerPr
);
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // Tab Body Router
+ // ─────────────────────────────────────────────────────────────────────────
+ Widget _buildTabBody(BuildContext context, GamificationProvider provider) {
+ switch (_activeTab) {
+ case 0: return _buildContributeTab(context, provider);
+ case 1: return _buildLeaderboardTab(context, provider);
+ case 2: return _buildAchievementsTab(context, provider);
+ case 3: return _buildShopTab(context, provider);
+ default: return const SizedBox();
+ }
+ }
+ // ═══════════════════════════════════════════════════════════════════════════
+ // TAB 0 — CONTRIBUTE
+ // ═══════════════════════════════════════════════════════════════════════════
+ Widget _buildContributeTab(BuildContext context, GamificationProvider provider) {
+ final theme = Theme.of(context);
+ return SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Submit an Event', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
+ const SizedBox(height: 4),
+ Text(
+ 'Fill in the details below. Earn up to 10 EP per approved submission.',
+ style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
+ ),
+ const SizedBox(height: 20),
+ _formCard([
+ _formField(_titleCtl, 'Event Name *', Icons.event, required: true),
+ _divider(),
+ _categoryDropdown(),
+ _divider(),
+ _districtDropdown(),
+ ]),
- Widget _buildForm(BuildContext ctx) {
- final theme = Theme.of(ctx);
- return Container(
- width: double.infinity,
- margin: const EdgeInsets.only(top: 18),
- padding: const EdgeInsets.fromLTRB(18, 20, 18, 28),
- decoration: BoxDecoration(
- color: theme.scaffoldBackgroundColor,
- borderRadius: BorderRadius.circular(_cornerRadius),
- boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 12, offset: const Offset(0, 6))],
+ const SizedBox(height: 12),
+
+ _formCard([
+ _dateTile(),
+ _divider(),
+ _timeTile(),
+ ]),
+
+ const SizedBox(height: 12),
+
+ _formCard([
+ _formField(_locationCtl, 'Location / Venue', Icons.location_on_outlined),
+ _divider(),
+ _formField(_organizerCtl, 'Organizer Name', Icons.person_outline),
+ _divider(),
+ _formField(_descriptionCtl, 'Description', Icons.notes_outlined, maxLines: 3),
+ ]),
+
+ const SizedBox(height: 12),
+
+ _formCard([
+ _formField(
+ _ticketPriceCtl, 'Ticket Price (₹)', Icons.confirmation_number_outlined,
+ keyboardType: TextInputType.number,
+ hint: 'Leave blank if free',
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ ),
+ _divider(),
+ _formField(_contactCtl, 'Contact Details', Icons.phone_outlined),
+ _divider(),
+ _formField(_websiteCtl, 'Website / Social Media', Icons.link_outlined),
+ ]),
+
+ const SizedBox(height: 12),
+
+ // Image picker
+ _buildImagePickerSection(theme),
+
+ const SizedBox(height: 24),
+
+ SizedBox(
+ width: double.infinity,
+ height: 52,
+ child: ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: _primary,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
+ elevation: 0,
+ ),
+ onPressed: _submitting ? null : () => _submitForm(provider),
+ child: _submitting
+ ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
+ : const Text('Submit for Verification', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+ Center(
+ child: Text(
+ 'Your submission will be reviewed by our team.\nApproved events earn EP immediately.',
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[500], fontSize: 11),
+ ),
+ ),
+ ],
+ ),
),
+ );
+ }
+
+ Widget _formCard(List children) {
+ return Container(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
+ ),
+ child: Column(children: children),
+ );
+ }
+
+ Widget _divider() => const Divider(height: 1, indent: 16, endIndent: 16, color: Color(0xFFF0F0F0));
+
+ Widget _formField(
+ TextEditingController ctl,
+ String label,
+ IconData icon, {
+ bool required = false,
+ int maxLines = 1,
+ TextInputType keyboardType = TextInputType.text,
+ String? hint,
+ List? 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(
+ 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(
+ 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: [
- // sheet title
- Center(
- child: Column(
- children: [
- Text('Contribute an Event', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)),
- const SizedBox(height: 8),
- Text('Share local events. Earn points for every verified submission!',
- textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
- ],
- ),
+ 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: 18),
-
- // small helper button
- Center(
- child: OutlinedButton(
- onPressed: () {
- ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Want to edit an existing event? (demo)')));
- },
- style: OutlinedButton.styleFrom(
- side: BorderSide(color: Colors.grey.shade300),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_cornerRadius - 6)),
- padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
- ),
- child: const Text('Want to edit an existing event?'),
- ),
- ),
-
- const SizedBox(height: 18),
-
- // Event Title
- Text('Event Title', style: theme.textTheme.labelLarge),
- const SizedBox(height: 8),
- _roundedTextField(controller: _titleCtl, hint: 'e.g. Local Food Festival'),
-
- const SizedBox(height: 14),
-
- // Category
- Text('Category', style: theme.textTheme.labelLarge),
- const SizedBox(height: 8),
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 12),
- decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
- child: DropdownButtonHideUnderline(
- child: DropdownButton(
- value: _selectedCategory,
- isExpanded: true,
- items: _categories.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
- onChanged: (v) => setState(() => _selectedCategory = v ?? _selectedCategory),
- icon: const Icon(Icons.keyboard_arrow_down),
- ),
- ),
- ),
-
- const SizedBox(height: 14),
-
- // Date
- Text('Date', style: theme.textTheme.labelLarge),
- const SizedBox(height: 8),
- GestureDetector(
- onTap: _pickDate,
- child: Container(
- height: 48,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- alignment: Alignment.centerLeft,
- decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
- child: Text(_selectedDate == null ? 'Select date' : '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}', style: theme.textTheme.bodyMedium),
- ),
- ),
-
- const SizedBox(height: 14),
-
- // Location
- Text('Location', style: theme.textTheme.labelLarge),
- const SizedBox(height: 8),
- _roundedTextField(controller: _locationCtl, hint: 'e.g. City Park, Calicut'),
-
- const SizedBox(height: 14),
-
- // Organizer
- Text('Organizer Name', style: theme.textTheme.labelLarge),
- const SizedBox(height: 8),
- _roundedTextField(controller: _organizerCtl, hint: 'Individual or Organization Name'),
-
- const SizedBox(height: 14),
-
- // Description
- Text('Description', style: theme.textTheme.labelLarge),
- const SizedBox(height: 8),
- TextField(
- controller: _descriptionCtl,
- minLines: 4,
- maxLines: 6,
- decoration: InputDecoration(
- hintText: 'Tell us more about the event...',
- filled: true,
- fillColor: theme.cardColor,
- border: OutlineInputBorder(borderRadius: BorderRadius.circular(_cornerRadius - 8), borderSide: BorderSide.none),
- contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
- ),
- ),
-
- const SizedBox(height: 18),
-
- // Event Images header
- Text('Event Images', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
-
- // Cover image
- Text('Cover Image', style: theme.textTheme.bodySmall),
- const SizedBox(height: 8),
- GestureDetector(
- onTap: _pickCoverImage,
- child: _imagePickerPlaceholder(file: _coverImageFile, label: 'Cover Image'),
- ),
-
- const SizedBox(height: 12),
-
- // Thumbnail image
- Text('Thumbnail', style: theme.textTheme.bodySmall),
- const SizedBox(height: 8),
- GestureDetector(
- onTap: _pickThumbnailImage,
- child: _imagePickerPlaceholder(file: _thumbImageFile, label: 'Thumbnail'),
- ),
-
- const SizedBox(height: 22),
-
- // Submit button
- SizedBox(
- width: double.infinity,
- height: 52,
- child: ElevatedButton(
- onPressed: _submitting ? null : _submit,
- style: ElevatedButton.styleFrom(
- backgroundColor: _primary,
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_cornerRadius - 6)),
+ 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),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
),
- child: _submitting
- ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.2))
- : const Text('Submit for Verification', style: TextStyle(fontWeight: FontWeight.w600)),
- ),
- ),
+ )
+ else
+ _addImageButton(full: true),
],
),
);
}
- Widget _roundedTextField({required TextEditingController controller, required String hint}) {
- final theme = Theme.of(context);
- return TextField(
- controller: controller,
- decoration: InputDecoration(
- hintText: hint,
- filled: true,
- fillColor: theme.cardColor,
- border: OutlineInputBorder(borderRadius: BorderRadius.circular(_cornerRadius - 8), borderSide: BorderSide.none),
- contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
+ Widget _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)),
+ ],
+ ],
+ ),
),
);
}
- Widget _imagePickerPlaceholder({XFile? file, required String label}) {
- final theme = Theme.of(context);
- if (file == null) {
- return Container(
- width: double.infinity,
- height: 120,
- decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
- child: Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.image, size: 28, color: theme.hintColor),
- const SizedBox(height: 8),
- Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
- ],
- ),
- ),
- );
- }
+ Future _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);
+ }
- // show picked image (file or network depending on platform)
- if (kIsWeb || file.path.startsWith('http')) {
- return ClipRRect(
- borderRadius: BorderRadius.circular(_cornerRadius - 8),
- child: Image.network(file.path, width: double.infinity, height: 120, fit: BoxFit.cover, errorBuilder: (_, __, ___) {
- return Container(
- width: double.infinity,
- height: 120,
- decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
- child: Center(child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
- );
- }),
- );
- } else {
- final f = File(file.path);
- if (!f.existsSync()) {
- return Container(
- width: double.infinity,
- height: 120,
- decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
- child: Center(child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
- );
+ Future _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 _pickImages() async {
+ try {
+ final List picked = await _picker.pickMultiImage(imageQuality: 80);
+ if (picked.isNotEmpty) {
+ setState(() {
+ _images = [..._images, ...picked].take(5).toList();
+ });
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick images: $e')));
}
- return ClipRRect(
- borderRadius: BorderRadius.circular(_cornerRadius - 8),
- child: Image.file(f, width: double.infinity, height: 120, fit: BoxFit.cover, errorBuilder: (_, __, ___) {
- return Container(
- width: double.infinity,
- height: 120,
- decoration: BoxDecoration(color: theme.cardColor, borderRadius: BorderRadius.circular(_cornerRadius - 8)),
- child: Center(child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor))),
- );
- }),
- );
}
}
- // ── Leaderboard state ──
- int _leaderboardTimeFilter = 0; // 0 = All Time, 1 = This Month
- int _leaderboardDistrictFilter = 0; // index into _districts
+ Future _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;
+ }
- static const List _districts = [
+ setState(() => _submitting = true);
+ try {
+ await provider.submitContribution({
+ 'title': _titleCtl.text.trim(),
+ 'category': _selectedCategory,
+ 'district': _selectedDistrict,
+ 'date': _selectedDate!.toIso8601String(),
+ 'time': _selectedTime?.format(context),
+ 'location': _locationCtl.text.trim(),
+ 'organizer_name': _organizerCtl.text.trim(),
+ 'description': _descriptionCtl.text.trim(),
+ 'ticket_price': _ticketPriceCtl.text.trim(),
+ 'contact': _contactCtl.text.trim(),
+ 'website': _websiteCtl.text.trim(),
+ 'images': _images.map((f) => f.path).toList(),
+ });
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('✅ Submitted for verification! You\'ll earn EP once approved.'),
+ backgroundColor: Color(0xFF16A34A),
+ ),
+ );
+ _clearForm();
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red));
+ }
+ } finally {
+ if (mounted) setState(() => _submitting = false);
+ }
+ }
+
+ void _clearForm() {
+ _titleCtl.clear();
+ _locationCtl.clear();
+ _organizerCtl.clear();
+ _descriptionCtl.clear();
+ _ticketPriceCtl.clear();
+ _contactCtl.clear();
+ _websiteCtl.clear();
+ setState(() {
+ _selectedDate = null;
+ _selectedTime = null;
+ _selectedCategory = _categories.first;
+ _selectedDistrict = _districts.first;
+ _images = [];
+ });
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // TAB 1 — LEADERBOARD (matches web version at mvnew.eventifyplus.com/contribute)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // Kerala districts matching the web version (leaderboard filter)
+ static const _lbDistricts = [
'Overall Kerala',
- 'Thiruvananthapuram',
- 'Kollam',
- 'Pathanamthitta',
- 'Alappuzha',
- 'Kottayam',
- 'Idukki',
- 'Ernakulam',
- 'Thrissur',
- 'Palakkad',
- 'Malappuram',
- 'Kozhikode',
- 'Wayanad',
- 'Kannur',
- 'Kasaragod',
+ 'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
+ 'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
+ 'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
- // Demo leaderboard data
- static const List