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 @@ [![Flutter](https://img.shields.io/badge/Flutter-%2302569B.svg?style=for-the-badge&logo=flutter&logoColor=white)](https://flutter.dev/) [![Dart](https://img.shields.io/badge/Dart-%230175C2.svg?style=for-the-badge&logo=dart&logoColor=white)](https://dart.dev/) [![Platform](https://img.shields.io/badge/Platform-iOS%20%7C%20Android%20%7C%20Web%20%7C%20Desktop-lightgrey?style=for-the-badge)](#) +[![Version](https://img.shields.io/badge/version-1.4.0--preview-blue?style=for-the-badge)](#) @@ -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> _leaderboardData = [ - {'name': 'Annette Black', 'points': 4628, 'level': 'Legend', 'events': 156}, - {'name': 'Jerome Bell', 'points': 4518, 'level': 'Legend', 'events': 152}, - {'name': 'Theresa Webb', 'points': 4368, 'level': 'Legend', 'events': 148}, - {'name': 'Courtney Henry', 'points': 4279, 'level': 'Legend', 'events': 149}, - {'name': 'Cameron Williamson', 'points': 4150, 'level': 'Legend', 'events': 144}, - {'name': 'Brooklyn Simmons', 'points': 4033, 'level': 'Legend', 'events': 139}, - {'name': 'Leslie Alexander', 'points': 3914, 'level': 'Champion', 'events': 134}, - {'name': 'Jenny Wilson', 'points': 3783, 'level': 'Champion', 'events': 132}, - ]; + // Format a points number with comma separator + " pts" suffix + static String _fmtPts(int ep) { + if (ep >= 1000) { + final s = ep.toString(); + final intPart = s.substring(0, s.length - 3); + final fracPart = s.substring(s.length - 3); + return '$intPart,$fracPart pts'; + } + return '$ep pts'; + } - // Demo achievements data - static const List> _achievementsData = [ - {'name': 'Newcomer', 'subtitle': 'First Event Posted', 'icon': Icons.star_outline, 'color': 0xFFDBEAFE, 'iconColor': 0xFF3B82F6, 'unlocked': true}, - {'name': 'Contributor', 'subtitle': '10th Event Posted within a month', 'icon': Icons.workspace_premium, 'color': 0xFFFEF9C3, 'iconColor': 0xFFEAB308, 'unlocked': true}, - {'name': 'On Fire!', 'subtitle': '3 Day Streak of logging in', 'icon': Icons.local_fire_department_outlined, 'color': 0xFFFFEDD5, 'iconColor': 0xFFF97316, 'unlocked': true, 'progress': 0.67}, - {'name': 'Verified', 'subtitle': 'Identity Verified successfully', 'icon': Icons.verified_outlined, 'color': 0xFFDCFCE7, 'iconColor': 0xFF22C55E, 'unlocked': true}, - {'name': 'Quality', 'subtitle': '5 Star Event Rating received', 'icon': Icons.lock_outline, 'color': 0xFFF1F5F9, 'iconColor': 0xFF94A3B8, 'unlocked': false}, - {'name': 'Community', 'subtitle': 'Referred 5 Friends to the platform', 'icon': Icons.people_outline, 'color': 0xFFE0E7FF, 'iconColor': 0xFF6366F1, 'unlocked': true, 'progress': 0.40}, - {'name': 'Expert', 'subtitle': 'Level 10 Reached in 3 months', 'icon': Icons.lock_outline, 'color': 0xFFF1F5F9, 'iconColor': 0xFF94A3B8, 'unlocked': false}, - {'name': 'Precision', 'subtitle': '100% Data Accuracy on all events', 'icon': Icons.lock_outline, 'color': 0xFFF1F5F9, 'iconColor': 0xFF94A3B8, 'unlocked': false}, - ]; + Widget _buildLeaderboardTab(BuildContext context, GamificationProvider provider) { + if (provider.isLoading && provider.leaderboard.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } - Widget _buildLeaderboard(BuildContext context) { - final theme = Theme.of(context); + final entries = provider.leaderboard; + final _matching = entries.where((e) => e.isCurrentUser).toList(); + final myEntry = _matching.isNotEmpty ? _matching.first : null; - return Container( - width: double.infinity, - margin: const EdgeInsets.only(top: 18), - padding: const EdgeInsets.fromLTRB(0, 16, 0, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Time filter: All Time / This Month ── - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _buildTimeToggle(theme), + return Column( + children: [ + // ── Time period toggle (top-right) + district scroll ────────────────── + _buildLeaderboardFilters(provider), + + Expanded( + child: Container( + color: const Color(0xFFFAFBFC), + child: CustomScrollView( + slivers: [ + // Podium top-3 + if (entries.length >= 3) + SliverToBoxAdapter(child: _buildPodium(entries.take(3).toList())), + + // Column headers + SliverToBoxAdapter( + child: Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + const SizedBox(width: 36, child: Text('RANK', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))), + const SizedBox(width: 44), + const Expanded(child: Text('USER', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))), + const SizedBox(width: 72, child: Text('POINTS', textAlign: TextAlign.right, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))), + const SizedBox(width: 8), + const SizedBox(width: 60, child: Text('LEVEL', textAlign: TextAlign.center, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))), + const SizedBox(width: 8), + const SizedBox(width: 36, child: Text('EVENTS', textAlign: TextAlign.right, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF9CA3AF), letterSpacing: 0.5))), + ], + ), + ), + ), + + // Ranked list (rank 4+) + SliverList( + delegate: SliverChildBuilderDelegate( + (ctx, i) { + final entry = entries.length > 3 ? entries[i + 3] : entries[i]; + return _buildRankRow(entry); + }, + childCount: entries.length > 3 ? entries.length - 3 : 0, + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 100)), ], ), ), - const SizedBox(height: 12), + ), - // ── District filter chips (horizontal scroll) ── + // My rank sticky card with Share + if (myEntry != null) SafeArea(top: false, child: _buildMyRankCard(myEntry)), + ], + ); + } + + Widget _buildLeaderboardFilters(GamificationProvider provider) { + return Container( + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Time period toggle — right-aligned + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _timePill('All Time', 'all_time', provider), + const SizedBox(width: 6), + _timePill('This Month', 'this_month', provider), + ], + ), + ), + // Horizontal scroll of district pills SizedBox( - height: 38, + height: 42, child: ListView.separated( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: _districts.length, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + itemCount: _lbDistricts.length, separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final active = i == _leaderboardDistrictFilter; + itemBuilder: (_, i) { + final d = _lbDistricts[i]; + final isActive = provider.leaderboardDistrict == d; return GestureDetector( - onTap: () => setState(() => _leaderboardDistrictFilter = i), + onTap: () => provider.setDistrict(d), child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration( - color: active ? _primary : Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: active ? _primary : Colors.grey.shade300), + color: isActive ? _primary : Colors.white, + border: Border.all(color: isActive ? _primary : const Color(0xFFD1D5DB)), + borderRadius: BorderRadius.circular(999), ), child: Text( - _districts[i], + d, style: TextStyle( - color: active ? Colors.white : Colors.grey.shade600, - fontWeight: FontWeight.w600, + color: isActive ? Colors.white : const Color(0xFF374151), + fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, fontSize: 13, ), ), @@ -696,75 +2000,137 @@ class _ContributeScreenState extends State with SingleTickerPr }, ), ), - const SizedBox(height: 24), - - // ── Podium (top 3) ── - _buildPodium(theme), - const SizedBox(height: 24), - - // ── Leaderboard table (rank 4+) ── - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - // Header - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - SizedBox(width: 32, child: Text('RANK', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500))), - const SizedBox(width: 8), - Expanded(child: Text('USER', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500))), - SizedBox(width: 60, child: Text('POINTS', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500))), - const SizedBox(width: 8), - SizedBox(width: 68, child: Text('LEVEL', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey.shade500), textAlign: TextAlign.center)), - const SizedBox(width: 8), - SizedBox(width: 32, child: Text('EVENTS', style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Colors.grey.shade500), textAlign: TextAlign.center)), - ], - ), - ), - // Rows (rank 4+) - ...List.generate( - _leaderboardData.length - 3, - (i) => _buildLeaderboardRow(theme, i + 3), - ), - ], - ), - ), + const Divider(height: 1, color: Color(0xFFE5E7EB)), ], ), ); } - Widget _buildTimeToggle(ThemeData theme) { - final labels = ['All Time', 'This Month']; - return Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(24), + Widget _timePill(String label, String key, GamificationProvider provider) { + final isActive = provider.leaderboardTimePeriod == key; + return GestureDetector( + onTap: () => provider.setTimePeriod(key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: isActive ? _primary : const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: TextStyle( + color: isActive ? Colors.white : const Color(0xFF6B7280), + fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, + fontSize: 12, + ), + ), ), + ); + } + + Widget _buildPodium(List top3) { + // Layout: [#2 left] [#1 centre, tallest] [#3 right] + final order = [top3[1], top3[0], top3[2]]; + final heights = [90.0, 120.0, 70.0]; + // Pillar colors matching the web: silver, gold/yellow, brown + final pillarColors = [ + const Color(0xFFBDBDBD), // 2nd: silver-grey + const Color(0xFFF59E0B), // 1st: gold/amber + const Color(0xFF92400E), // 3rd: bronze-brown + ]; + // Badge colors (overlaid on avatar) + final badgeColors = [ + const Color(0xFFD97706), // #2: orange + const Color(0xFF1D4ED8), // #1: blue + const Color(0xFF92400E), // #3: brown + ]; + final ranks = [2, 1, 3]; + + return Container( + color: const Color(0xFFFAFBFC), + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), child: Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(labels.length, (i) { - final active = i == _leaderboardTimeFilter; - return GestureDetector( - onTap: () => setState(() => _leaderboardTimeFilter = i), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: active ? _primary : Colors.transparent, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - labels[i], - style: TextStyle( - color: active ? Colors.white : Colors.grey.shade600, - fontWeight: FontWeight.w600, - fontSize: 13, + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(3, (i) { + final e = order[i]; + final avatarSize = i == 1 ? 64.0 : 52.0; // #1 is larger + return Expanded( + child: Column( + children: [ + // Avatar with rank badge overlaid + Stack( + clipBehavior: Clip.none, + children: [ + // Avatar circle + Container( + width: avatarSize, + height: avatarSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFE0F2FE), + border: Border.all(color: pillarColors[i], width: 2.5), + ), + child: Center( + child: Text( + e.username.isNotEmpty ? e.username[0].toUpperCase() : '?', + style: TextStyle( + fontSize: i == 1 ? 24 : 18, + fontWeight: FontWeight.w800, + color: pillarColors[i], + ), + ), + ), + ), + // Rank badge — bottom-right corner of avatar + Positioned( + bottom: -2, + right: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: badgeColors[i], + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + child: Center( + child: Text( + '${ranks[i]}', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w800), + ), + ), + ), + ), + ], ), - ), + const SizedBox(height: 8), + Text( + e.username.split(' ').first, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Color(0xFF111827)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + _fmtPts(e.lifetimeEp), + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF0F45CF)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + // Podium pillar + Container( + height: heights[i], + decoration: BoxDecoration( + color: pillarColors[i], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(6), + topRight: Radius.circular(6), + ), + ), + ), + ], ), ); }), @@ -772,164 +2138,98 @@ class _ContributeScreenState extends State with SingleTickerPr ); } - Widget _buildPodium(ThemeData theme) { - if (_leaderboardData.length < 3) return const SizedBox.shrink(); - - final first = _leaderboardData[0]; // #1 - final second = _leaderboardData[1]; // #2 - final third = _leaderboardData[2]; // #3 - - // Podium colors - const goldColor = Color(0xFFFBBF24); - const silverColor = Color(0xFFD1D5DB); - const bronzeColor = Color(0xFFF97316); - - Widget podiumSlot(Map user, int rank, Color pillarColor, double pillarHeight, Color badgeColor) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Avatar with rank badge - Stack( - clipBehavior: Clip.none, - children: [ - CircleAvatar( - radius: rank == 1 ? 32 : 26, - backgroundColor: badgeColor.withOpacity(0.2), - child: Icon(Icons.person, size: rank == 1 ? 32 : 26, color: badgeColor), - ), - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: badgeColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - alignment: Alignment.center, - child: Text('$rank', style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w800)), - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - user['name'] as String, - style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - '${_formatNumber(user['points'] as int)} pts', - style: TextStyle(color: _primary, fontWeight: FontWeight.w600, fontSize: 12), - ), - const SizedBox(height: 6), - // Pillar - Container( - width: 80, - height: pillarHeight, - decoration: BoxDecoration( - color: pillarColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), - ), - ), - ], - ); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // #2 – left - Expanded(child: podiumSlot(second, 2, silverColor, 70, Colors.grey.shade500)), - const SizedBox(width: 8), - // #1 – center (tallest) - Expanded(child: podiumSlot(first, 1, goldColor, 100, goldColor)), - const SizedBox(width: 8), - // #3 – right - Expanded(child: podiumSlot(third, 3, bronzeColor, 55, bronzeColor)), - ], - ), - ); - } - - Widget _buildLeaderboardRow(ThemeData theme, int index) { - final user = _leaderboardData[index]; - final rank = index + 1; - final level = user['level'] as String; - - Color levelColor; - Color levelBg; - switch (level) { - case 'Legend': - levelColor = const Color(0xFF16A34A); - levelBg = const Color(0xFFDCFCE7); - break; - case 'Champion': - levelColor = const Color(0xFF9333EA); - levelBg = const Color(0xFFF3E8FF); - break; - default: - levelColor = Colors.grey; - levelBg = Colors.grey.shade100; - } + Widget _buildRankRow(LeaderboardEntry entry) { + final tierColor = _tierColors[entry.tier]!; + final isMe = entry.isCurrentUser; return Container( - margin: const EdgeInsets.only(bottom: 2), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), decoration: BoxDecoration( - color: Colors.white, - border: Border(bottom: BorderSide(color: Colors.grey.shade100)), + color: isMe ? const Color(0xFFEFF6FF) : Colors.white, + border: const Border(bottom: BorderSide(color: Color(0xFFE5E7EB), width: 0.8)), ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - SizedBox(width: 32, child: Text('$rank', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: Colors.grey.shade700))), - const SizedBox(width: 8), - CircleAvatar( - radius: 18, - backgroundColor: Colors.grey.shade200, - child: Icon(Icons.person, size: 20, color: Colors.grey.shade500), + // Rank number + SizedBox( + width: 36, + child: Text( + '${entry.rank}', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 16, + color: isMe ? _primary : const Color(0xFF111827), + ), + ), ), - const SizedBox(width: 10), + // Avatar circle + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFE0F2FE), + border: Border.all(color: tierColor.withOpacity(0.5), width: 1.5), + ), + child: Center( + child: Text( + entry.username.isNotEmpty ? entry.username[0].toUpperCase() : '?', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: tierColor), + ), + ), + ), + const SizedBox(width: 8), + // Name Expanded( child: Text( - user['name'] as String, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + entry.username + (isMe ? ' (You)' : ''), + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: isMe ? _primary : const Color(0xFF111827), + ), + maxLines: 1, overflow: TextOverflow.ellipsis, ), ), + // Points + SizedBox( + width: 80, + child: Text( + _fmtPts(entry.lifetimeEp), + textAlign: TextAlign.right, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF0F45CF)), + ), + ), + const SizedBox(width: 8), + // Level badge SizedBox( width: 60, - child: Text( - '${_formatNumber(user['points'] as int)} pts', - style: TextStyle(color: _primary, fontWeight: FontWeight.w600, fontSize: 12), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: tierColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + tierLabel(entry.tier), + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: tierColor), + textAlign: TextAlign.center, + ), + ), ), ), const SizedBox(width: 8), - Container( - width: 68, - padding: const EdgeInsets.symmetric(vertical: 4), - decoration: BoxDecoration( - color: levelBg, - borderRadius: BorderRadius.circular(12), - ), - alignment: Alignment.center, - child: Text(level, style: TextStyle(color: levelColor, fontWeight: FontWeight.w600, fontSize: 11)), - ), - const SizedBox(width: 8), + // Events added SizedBox( - width: 32, + width: 36, child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, children: [ - Icon(Icons.calendar_today, size: 10, color: Colors.grey.shade400), - const SizedBox(width: 2), - Text('${user['events']}', style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + const Icon(Icons.calendar_today_outlined, size: 12, color: Color(0xFF9CA3AF)), + const SizedBox(width: 3), + Text('${entry.eventsCount}', style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280), fontWeight: FontWeight.w500)), ], ), ), @@ -938,183 +2238,389 @@ class _ContributeScreenState extends State with SingleTickerPr ); } - String _formatNumber(int n) { - if (n >= 1000) { - return '${(n / 1000).toStringAsFixed(n % 1000 == 0 ? 0 : 0)},${(n % 1000).toString().padLeft(3, '0')}'; - } - return '$n'; - } - - // ── Achievements Tab ── - - Widget _buildAchievements(BuildContext context) { - final theme = Theme.of(context); - + Widget _buildMyRankCard(LeaderboardEntry me) { return Container( - width: double.infinity, - margin: const EdgeInsets.only(top: 18), - padding: const EdgeInsets.fromLTRB(16, 20, 16, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 10, offset: const Offset(0, -4))], + ), + child: Row( children: [ - Text( - 'Your Badges', - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration(color: _primary, borderRadius: BorderRadius.circular(10)), + child: Text('Your Rank: #${me.rank}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)), ), - const SizedBox(height: 16), - - // Badge grid - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 1.05, + const SizedBox(width: 10), + Expanded( + child: Text('${_fmtPts(me.lifetimeEp)} · ${tierLabel(me.tier)}', + style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12)), + ), + GestureDetector( + onTap: () { + Share.share( + 'I\'m ranked #${me.rank} on @EventifyPlus with ${me.lifetimeEp} EP! 🏆 ' + 'Discover & contribute to events near you at eventifyplus.com', + subject: 'My Eventify.Plus Leaderboard Rank', + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration(border: Border.all(color: _primary), borderRadius: BorderRadius.circular(10)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.share_outlined, size: 14, color: _primary), + const SizedBox(width: 4), + Text('Share', style: TextStyle(color: _primary, fontSize: 12, fontWeight: FontWeight.w600)), + ], + ), ), - itemCount: _achievementsData.length, - itemBuilder: (context, i) => _buildBadgeCard(theme, _achievementsData[i]), ), ], ), ); } - Widget _buildBadgeCard(ThemeData theme, Map badge) { - final isUnlocked = badge['unlocked'] as bool; - final progress = badge['progress'] as double?; - final badgeColor = Color(badge['color'] as int); - final iconColor = Color(badge['iconColor'] as int); + // ═══════════════════════════════════════════════════════════════════════════ + // TAB 2 — ACHIEVEMENTS + // ═══════════════════════════════════════════════════════════════════════════ + Widget _buildAchievementsTab(BuildContext context, GamificationProvider provider) { + final theme = Theme.of(context); + if (provider.isLoading && provider.achievements.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + final badges = provider.achievements; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Your Badges', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text( + 'Earn badges by contributing events and climbing tiers.', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]), + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: badges.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.95, + ), + itemBuilder: (_, i) => _buildBadgeCard(badges[i], theme), + ), + ], + ), + ); + } + + Widget _buildBadgeCard(AchievementBadge badge, ThemeData theme) { + final icon = _badgeIcons[badge.iconName] ?? Icons.star_outline; return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.shade100), - boxShadow: [ - BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 8, offset: const Offset(0, 2)), - ], + borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))], + border: badge.isUnlocked ? Border.all(color: _primary.withOpacity(0.3)) : null, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Icon circle Container( - width: 44, - height: 44, + width: 52, + height: 52, decoration: BoxDecoration( - color: badgeColor, shape: BoxShape.circle, + color: badge.isUnlocked ? _primary.withOpacity(0.12) : Colors.grey[100], ), child: Icon( - badge['icon'] as IconData, - color: iconColor, - size: 22, + badge.isUnlocked ? icon : Icons.lock_outline, + size: 26, + color: badge.isUnlocked ? _primary : Colors.grey[400], ), ), - const Spacer(), - // Name + lock indicator - Row( - children: [ - Expanded( - child: Text( - badge['name'] as String, - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 14, - color: isUnlocked ? Colors.black87 : Colors.grey.shade400, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (!isUnlocked) - Icon(Icons.lock_outline, size: 14, color: Colors.grey.shade400), - ], + const SizedBox(height: 10), + Text( + badge.title, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 13, + color: badge.isUnlocked ? Colors.black87 : Colors.grey[500], + ), ), const SizedBox(height: 4), Text( - badge['subtitle'] as String, - style: TextStyle( - fontSize: 11, - color: isUnlocked ? Colors.grey.shade600 : Colors.grey.shade400, - height: 1.3, - ), + badge.description, + textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 10, color: Colors.grey[500]), ), - - // Progress bar if applicable - if (progress != null) ...[ + if (!badge.isUnlocked && badge.progress > 0) ...[ const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), - child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: progress), - duration: const Duration(milliseconds: 800), - builder: (_, val, __) => LinearProgressIndicator( - value: val, - minHeight: 5, - valueColor: AlwaysStoppedAnimation(_primary), - backgroundColor: Colors.grey.shade200, - ), - ), - ), - const SizedBox(height: 4), - Align( - alignment: Alignment.centerRight, - child: Text( - '${(progress * 100).toInt()}%', - style: TextStyle(fontSize: 11, color: Colors.grey.shade500, fontWeight: FontWeight.w600), + child: LinearProgressIndicator( + value: badge.progress, + minHeight: 4, + valueColor: AlwaysStoppedAnimation(_primary.withOpacity(0.6)), + backgroundColor: Colors.grey[200]!, ), ), + const SizedBox(height: 3), + Text('${(badge.progress * 100).round()}%', style: TextStyle(fontSize: 9, color: Colors.grey[500])), ], + if (badge.isUnlocked) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: const Color(0xFFDCFCE7), borderRadius: BorderRadius.circular(8)), + child: const Text('Unlocked', style: TextStyle(fontSize: 9, color: Color(0xFF16A34A), fontWeight: FontWeight.w600)), + ), + ), ], ), ); } - @override - Widget build(BuildContext context) { - // Switch content based on active tab - Widget tabContent; - switch (_activeTab) { - case 1: - tabContent = _buildLeaderboard(context); - break; - case 2: - tabContent = _buildAchievements(context); - break; - default: - tabContent = Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: _buildForm(context), - ); + // ═══════════════════════════════════════════════════════════════════════════ + // TAB 3 — SHOP + // ═══════════════════════════════════════════════════════════════════════════ + Widget _buildShopTab(BuildContext context, GamificationProvider provider) { + final theme = Theme.of(context); + final rp = provider.profile?.currentRp ?? 0; + + if (provider.isLoading && provider.shopItems.isEmpty) { + return const Center(child: CircularProgressIndicator()); } - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - body: SafeArea( - bottom: false, - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( + return Column( + children: [ + // RP balance banner + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( children: [ - _buildHeader(context), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: KeyedSubtree( - key: ValueKey(_activeTab), - child: tabContent, + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFF0B63D6), Color(0xFF3B82F6)]), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.monetization_on_outlined, color: Colors.white, size: 16), + const SizedBox(width: 6), + Text('$rp RP Balance', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14)), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '10 EP = 1 RP • Converted monthly', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600], fontSize: 11), ), ), - const SizedBox(height: 36), ], ), ), + + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16), + itemCount: provider.shopItems.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.78, + ), + itemBuilder: (_, i) => _buildShopCard(context, provider, provider.shopItems[i]), + ), + ), + ], + ); + } + + Widget _buildShopCard(BuildContext context, GamificationProvider provider, ShopItem item) { + final rp = provider.profile?.currentRp ?? 0; + final canRedeem = rp >= item.rpCost && item.stockQuantity > 0; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon area + Container( + height: 60, + decoration: BoxDecoration( + color: _primary.withOpacity(0.07), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Icon( + item.stockQuantity == 0 ? Icons.inventory_2_outlined : Icons.card_giftcard_outlined, + size: 30, + color: item.stockQuantity == 0 ? Colors.grey[400] : _primary, + ), + ), + ), + const SizedBox(height: 10), + Text(item.name, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Text(item.description, style: TextStyle(fontSize: 10, color: Colors.grey[500]), maxLines: 2, overflow: TextOverflow.ellipsis), + const Spacer(), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF59E0B).withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text('${item.rpCost} RP', style: const TextStyle(color: Color(0xFFD97706), fontSize: 11, fontWeight: FontWeight.w700)), + ), + const SizedBox(width: 4), + if (item.stockQuantity == 0) + const Text('Out of stock', style: TextStyle(fontSize: 9, color: Colors.redAccent)) + else + Text('${item.stockQuantity} left', style: TextStyle(fontSize: 9, color: Colors.grey[500])), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 34, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: canRedeem ? _primary : Colors.grey[200], + foregroundColor: canRedeem ? Colors.white : Colors.grey[400], + elevation: 0, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: canRedeem ? () => _confirmRedeem(context, provider, item) : null, + child: Text( + item.stockQuantity == 0 ? 'Out of Stock' : canRedeem ? 'Redeem' : 'Need ${item.rpCost - rp} more RP', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), ), ); } + + Future _confirmRedeem(BuildContext context, GamificationProvider provider, ShopItem item) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Confirm Redemption', style: TextStyle(fontWeight: FontWeight.w700)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.name, style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text('This will deduct ${item.rpCost} RP from your balance.', style: TextStyle(color: Colors.grey[600], fontSize: 13)), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: _primary), + onPressed: () => Navigator.pop(context, true), + child: const Text('Confirm', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + try { + final voucherCode = await provider.redeemItem(item.id); + if (!mounted) return; + await showDialog( + context: context, + builder: (_) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.check_circle, color: Color(0xFF16A34A)), + SizedBox(width: 8), + Text('Redeemed!', style: TextStyle(fontWeight: FontWeight.w700)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Your voucher code for ${item.name}:', style: TextStyle(color: Colors.grey[600], fontSize: 13)), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Text( + voucherCode, + style: const TextStyle(fontFamily: 'monospace', fontWeight: FontWeight.w700, fontSize: 16, letterSpacing: 2), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 8), + Text('Save this code — it will not be shown again.', style: TextStyle(color: Colors.grey[500], fontSize: 11)), + ], + ), + actions: [ + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: _primary), + onPressed: () { + Clipboard.setData(ClipboardData(text: voucherCode)); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Voucher code copied!'), backgroundColor: Color(0xFF16A34A)), + ); + }, + child: const Text('Copy & Close', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Redemption failed: $e'), backgroundColor: Colors.red)); + } + } + } } diff --git a/lib/screens/home_desktop_screen.dart b/lib/screens/home_desktop_screen.dart index 2400a0e..b7608d3 100644 --- a/lib/screens/home_desktop_screen.dart +++ b/lib/screens/home_desktop_screen.dart @@ -10,7 +10,10 @@ import 'profile_screen.dart'; import 'booking_screen.dart'; import 'settings_screen.dart'; import 'learn_more_screen.dart'; +import 'contribute_screen.dart'; import '../core/app_decoration.dart'; +import '../features/gamification/providers/gamification_provider.dart'; +import 'package:provider/provider.dart'; class HomeDesktopScreen extends StatefulWidget { final bool skipSidebarEntranceAnimation; @@ -978,35 +981,9 @@ class _HomeDesktopScreenState extends State with SingleTicker case 3: return BookingScreen(onBook: () {}, image: ''); case 4: - // Contribute placeholder (kept simple) - return SingleChildScrollView( - padding: const EdgeInsets.all(28), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Contribute', style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 14), - const Text('Submit events or contact the Eventify team.'), - const SizedBox(height: 24), - Card( - elevation: 1, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.all(18), - child: Column(children: [ - TextField(decoration: InputDecoration(labelText: 'Event title', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)))), - const SizedBox(height: 12), - TextField(decoration: InputDecoration(labelText: 'Location', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)))), - const SizedBox(height: 12), - TextField(maxLines: 4, decoration: InputDecoration(labelText: 'Description', border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)))), - const SizedBox(height: 12), - Row(children: [ - ElevatedButton(onPressed: () {}, child: const Text('Submit')), - const SizedBox(width: 12), - OutlinedButton(onPressed: () {}, child: const Text('Reset')), - ]) - ]), - ), - ), - ]), + return ChangeNotifierProvider( + create: (_) => GamificationProvider(), + child: const ContributeScreen(), ); case 5: return const SettingsScreen(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 388ad25..11d4ff3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../features/events/models/event_models.dart'; import '../features/events/services/events_service.dart'; @@ -13,6 +14,8 @@ import 'learn_more_screen.dart'; import 'search_screen.dart'; import '../core/app_decoration.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:provider/provider.dart'; +import '../features/gamification/providers/gamification_provider.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({Key? key}) : super(key: key); @@ -78,13 +81,19 @@ class _HomeScreenState extends State with SingleTickerProviderStateM _pincode = prefs.getString('pincode') ?? 'all'; try { - final types = await _events_service_getEventTypesSafe(); - final events = await _events_service_getEventsSafe(_pincode); + // Fetch types and events in parallel for faster loading + final results = await Future.wait([ + _events_service_getEventTypesSafe(), + _events_service_getEventsSafe(_pincode), + ]); + final types = results[0] as List; + final events = results[1] as List; if (mounted) { setState(() { _types = types; _events = events; _selectedTypeId = -1; + _cachedFilteredEvents = null; // invalidate cache }); } } catch (e) { @@ -157,10 +166,15 @@ class _HomeScreenState extends State with SingleTickerProviderStateM child: imageUrl != null && imageUrl.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, + child: CachedNetworkImage( + imageUrl: imageUrl, fit: BoxFit.contain, - errorBuilder: (_, __, ___) => Icon( + placeholder: (_, __) => Icon( + icon ?? Icons.category, + size: 36, + color: selected ? Colors.white.withOpacity(0.5) : theme.colorScheme.primary.withOpacity(0.3), + ), + errorWidget: (_, __, ___) => Icon( icon ?? Icons.category, size: 36, color: selected ? Colors.white : theme.colorScheme.primary, @@ -362,7 +376,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM children: [ _buildHomeContent(), // index 0 const CalendarScreen(), // index 1 - const ContributeScreen(), // index 2 (full page, scrollable) + ChangeNotifierProvider( + create: (_) => GamificationProvider(), + child: const ContributeScreen(), + ), // index 2 (full page, scrollable) const ProfileScreen(), // index 3 ], ), @@ -445,11 +462,21 @@ class _HomeScreenState extends State with SingleTickerProviderStateM String _selectedDateFilter = ''; DateTime? _selectedCustomDate; + // Cached filtered events to avoid repeated DateTime.parse() calls + List? _cachedFilteredEvents; + String _cachedFilterKey = ''; + /// Returns the subset of [_events] that match the active date-filter chip. - /// If no chip is selected the full list is returned. + /// Uses caching to avoid re-parsing dates on every access. List get _filteredEvents { if (_selectedDateFilter.isEmpty) return _events; + // Build a cache key from filter state + final cacheKey = '$_selectedDateFilter|$_selectedCustomDate|${_events.length}'; + if (_cachedFilteredEvents != null && _cachedFilterKey == cacheKey) { + return _cachedFilteredEvents!; + } + final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -481,7 +508,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM return _events; } - return _events.where((e) { + _cachedFilteredEvents = _events.where((e) { try { final eStart = DateTime.parse(e.startDate); final eEnd = DateTime.parse(e.endDate); @@ -491,6 +518,8 @@ class _HomeScreenState extends State with SingleTickerProviderStateM return false; } }).toList(); + _cachedFilterKey = cacheKey; + return _cachedFilteredEvents!; } Future _onDateChipTap(String label) async { @@ -501,6 +530,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM setState(() { _selectedCustomDate = picked; _selectedDateFilter = 'Date'; + _cachedFilteredEvents = null; // invalidate cache }); _showFilteredEventsSheet( '${picked.day.toString().padLeft(2, '0')} ${_monthName(picked.month)} ${picked.year}', @@ -509,12 +539,14 @@ class _HomeScreenState extends State with SingleTickerProviderStateM setState(() { _selectedDateFilter = ''; _selectedCustomDate = null; + _cachedFilteredEvents = null; }); } } else { setState(() { _selectedDateFilter = label; _selectedCustomDate = null; + _cachedFilteredEvents = null; // invalidate cache }); _showFilteredEventsSheet(label); } @@ -663,12 +695,17 @@ class _HomeScreenState extends State with SingleTickerProviderStateM if (imageUrl != null && imageUrl.isNotEmpty && imageUrl.startsWith('http')) { imageWidget = ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, + child: CachedNetworkImage( + imageUrl: imageUrl, width: 80, height: 80, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + placeholder: (_, __) => Container( + width: 80, height: 80, + decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)), + child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + ), + errorWidget: (_, __, ___) => Container( width: 80, height: 80, decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)), child: Icon(Icons.image, color: Colors.grey.shade400), @@ -1426,10 +1463,16 @@ class _HomeScreenState extends State with SingleTickerProviderStateM children: [ // Background image img != null && img.isNotEmpty - ? Image.network( - img, + ? CachedNetworkImage( + imageUrl: img, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => Container( + color: const Color(0xFF374151), + child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white38))), + ), + errorWidget: (_, __, ___) => Container( color: const Color(0xFF374151), child: const Icon(Icons.image, color: Colors.white38, size: 40), ), @@ -1613,7 +1656,14 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ClipRRect( borderRadius: BorderRadius.circular(12), child: img != null && img.isNotEmpty - ? Image.network(img, width: 96, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(width: 96, color: theme.dividerColor)) + ? CachedNetworkImage( + imageUrl: img, + width: 96, + height: double.infinity, + fit: BoxFit.cover, + placeholder: (_, __) => Container(width: 96, color: theme.dividerColor, child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))), + errorWidget: (_, __, ___) => Container(width: 96, color: theme.dividerColor), + ) : Container(width: 96, height: double.infinity, color: theme.dividerColor), ), const SizedBox(width: 14), @@ -1671,12 +1721,21 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ClipRRect( borderRadius: BorderRadius.circular(18), child: img != null && img.isNotEmpty - ? Image.network( - img, + ? CachedNetworkImage( + imageUrl: img, width: 220, height: 180, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + placeholder: (_, __) => Container( + width: 220, + height: 180, + decoration: BoxDecoration( + color: theme.dividerColor, + borderRadius: BorderRadius.circular(18), + ), + child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))), + ), + errorWidget: (_, __, ___) => Container( width: 220, height: 180, decoration: BoxDecoration( @@ -1833,12 +1892,21 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), child: img != null && img.isNotEmpty - ? Image.network( - img, + ? CachedNetworkImage( + imageUrl: img, width: double.infinity, height: 200, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + placeholder: (_, __) => Container( + width: double.infinity, + height: 200, + decoration: BoxDecoration( + color: theme.dividerColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: const Center(child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2))), + ), + errorWidget: (_, __, ___) => Container( width: double.infinity, height: 200, decoration: BoxDecoration( @@ -1961,7 +2029,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM try { final all = await _eventsService.getEventsByPincode(_pincode); final filtered = id == -1 ? all : all.where((e) => e.eventTypeId == id).toList(); - if (mounted) setState(() => _events = filtered); + if (mounted) setState(() { _events = filtered; _cachedFilteredEvents = null; }); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); } diff --git a/lib/screens/learn_more_screen.dart b/lib/screens/learn_more_screen.dart index 52b24ea..be4cb3f 100644 --- a/lib/screens/learn_more_screen.dart +++ b/lib/screens/learn_more_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../features/events/models/event_models.dart'; import '../features/events/services/events_service.dart'; @@ -430,10 +431,21 @@ class _LearnMoreScreenState extends State { child: Stack( fit: StackFit.expand, children: [ - Image.network( - images[_currentPage], + CachedNetworkImage( + imageUrl: images[_currentPage], fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)], + ), + ), + ), + errorWidget: (_, __, ___) => Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, @@ -476,11 +488,15 @@ class _LearnMoreScreenState extends State { controller: _pageController, onPageChanged: (i) => setState(() => _currentPage = i), itemCount: images.length, - itemBuilder: (_, i) => Image.network( - images[i], + itemBuilder: (_, i) => CachedNetworkImage( + imageUrl: images[i], fit: BoxFit.cover, width: double.infinity, - errorBuilder: (_, __, ___) => Container( + placeholder: (_, __) => Container( + color: theme.dividerColor, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + errorWidget: (_, __, ___) => Container( color: theme.dividerColor, child: Icon(Icons.broken_image, size: 48, color: theme.hintColor), ), @@ -672,10 +688,10 @@ class _LearnMoreScreenState extends State { child: Stack( children: [ Positioned.fill( - child: Image.network( - 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=', + child: CachedNetworkImage( + imageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=', fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( + errorWidget: (_, __, ___) => Container( color: const Color(0xFFE8EAF6), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3802def..12618bb 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import geolocator_apple import path_provider_foundation import share_plus import shared_preferences_foundation +import sqflite_darwin import url_launcher_macos import video_player_avfoundation @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 4bd3cbd..0bb2fa0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -182,6 +206,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_launcher_icons: dependency: "direct dev" description: @@ -520,6 +552,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -616,6 +664,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" sanitize_html: dependency: transitive description: @@ -717,6 +781,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -749,6 +853,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" table_calendar: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2d00c28..db5c9ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: figma description: A Flutter event app publish_to: 'none' -version: 1.0.0+1 +version: 1.4.0+14 environment: sdk: ">=2.17.0 <3.0.0" @@ -19,7 +19,9 @@ dependencies: google_maps_flutter: ^2.5.0 url_launcher: ^6.2.1 share_plus: ^7.2.1 + provider: ^6.1.2 video_player: ^2.8.1 + cached_network_image: ^3.3.1 dev_dependencies: flutter_test: diff --git a/run_web.sh b/run_web.sh new file mode 100755 index 0000000..11052ba --- /dev/null +++ b/run_web.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /Users/bshtechnologies/Documents/Eventify-frontend +exec flutter run -d web-server --web-hostname 0.0.0.0 --web-port "${PORT:-8080}"