From e365361451562843576c484afea03c14bf0c1bb8 Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Sat, 4 Apr 2026 16:51:30 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=2011=20high-prior?= =?UTF-8?q?ity=20gaps=20implemented=20across=20home,=20auth,=20gamificatio?= =?UTF-8?q?n,=20profile,=20and=20event=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 gaps completed: - HOME-001: Hero slider pause-on-touch (GestureDetector wraps PageView) - HOME-003: Calendar bottom sheet with TableCalendar (replaces custom dialog) - AUTH-004: District dropdown on signup (14 Kerala districts) - EVT-003: Mobile sticky "Book Now" bar + desktop CTA wired to CheckoutScreen - ACH-001: Real achievements from dashboard API with fallback defaults - GAM-002: 3-card EP row (Lifetime EP / Liquid EP / Reward Points) - GAM-005: Horizontal tier roadmap Bronze→Silver→Gold→Platinum→Diamond - CTR-001: Submission status chips (PENDING/APPROVED/REJECTED) - CTR-002: +EP badge on approved submissions - PROF-003: Gamification cards on profile screen with Consumer - UX-001: Shimmer skeleton loaders (shimmer ^3.0.0) replacing CircularProgressIndicator Already complete (verified, no changes needed): - HOME-002: Category shelves already built in _buildTypeSection() - LDR-002: Podium visualization already built (_buildPodium / _buildDesktopPodium) - BOOK-004: UPI handled natively by Razorpay SDK Deferred: LOC-001/002 (needs Django haversine endpoint) Skipped: AUTH-002 (OTP needs SMS provider decision) Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/features/auth/services/auth_service.dart | 15 +- .../models/gamification_models.dart | 13 + .../providers/gamification_provider.dart | 21 +- .../services/gamification_service.dart | 59 ++- lib/screens/contribute_screen.dart | 216 ++++++++++ lib/screens/home_screen.dart | 368 ++++++++---------- lib/screens/learn_more_screen.dart | 53 ++- lib/screens/login_screen.dart | 15 + lib/screens/profile_screen.dart | 62 +++ lib/widgets/skeleton_loader.dart | 134 +++++++ pubspec.lock | 8 + pubspec.yaml | 1 + 12 files changed, 715 insertions(+), 250 deletions(-) create mode 100644 lib/widgets/skeleton_loader.dart diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart index 59c562d..c93f21c 100644 --- a/lib/features/auth/services/auth_service.dart +++ b/lib/features/auth/services/auth_service.dart @@ -78,15 +78,20 @@ class AuthService { required String email, required String phoneNumber, required String password, + String? district, }) async { try { + final body = { + "email": email, + "phone_number": phoneNumber, + "password": password, + }; + if (district != null && district.isNotEmpty) { + body["district"] = district; + } final res = await _api.post( ApiEndpoints.register, - body: { - "email": email, - "phone_number": phoneNumber, - "password": password, - }, + body: body, requiresAuth: false, ); diff --git a/lib/features/gamification/models/gamification_models.dart b/lib/features/gamification/models/gamification_models.dart index 041947c..ccabc0c 100644 --- a/lib/features/gamification/models/gamification_models.dart +++ b/lib/features/gamification/models/gamification_models.dart @@ -243,10 +243,12 @@ class SubmissionModel { class DashboardResponse { final UserGamificationProfile profile; final List submissions; + final List achievements; const DashboardResponse({ required this.profile, this.submissions = const [], + this.achievements = const [], }); } @@ -330,4 +332,15 @@ class AchievementBadge { required this.isUnlocked, required this.progress, }); + + factory AchievementBadge.fromJson(Map json) { + return AchievementBadge( + id: (json['id'] ?? json['badge_id'] ?? '').toString(), + title: (json['title'] ?? json['name'] ?? '').toString(), + description: (json['description'] ?? '').toString(), + iconName: (json['icon_name'] ?? json['icon'] ?? 'star').toString(), + isUnlocked: json['is_unlocked'] == true || json['unlocked'] == true, + progress: (json['progress'] as num?)?.toDouble() ?? 0.0, + ); + } } diff --git a/lib/features/gamification/providers/gamification_provider.dart b/lib/features/gamification/providers/gamification_provider.dart index 9406c37..61c11e3 100644 --- a/lib/features/gamification/providers/gamification_provider.dart +++ b/lib/features/gamification/providers/gamification_provider.dart @@ -24,10 +24,19 @@ class GamificationProvider extends ChangeNotifier { bool isLoading = false; String? error; + // TTL guard — prevents redundant API calls from multiple screens + DateTime? _lastLoadTime; + static const _loadTtl = Duration(minutes: 2); + // --------------------------------------------------------------------------- - // Load everything at once (called when ContributeScreen is mounted) + // Load everything at once (called when ContributeScreen or ProfileScreen mounts) // --------------------------------------------------------------------------- - Future loadAll() async { + Future loadAll({bool force = false}) async { + // Skip if recently loaded (within 2 minutes) unless forced + if (!force && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) { + return; + } + isLoading = true; error = null; notifyListeners(); @@ -50,7 +59,13 @@ class GamificationProvider extends ChangeNotifier { totalParticipants = lbResponse.totalParticipants; shopItems = results[2] as List; - achievements = results[3] as List; + + // Prefer achievements from dashboard API; fall back to getAchievements() + final dashAchievements = dashboard.achievements; + final fetchedAchievements = results[3] as List; + achievements = dashAchievements.isNotEmpty ? dashAchievements : fetchedAchievements; + + _lastLoadTime = DateTime.now(); } catch (e) { error = userFriendlyError(e); } finally { diff --git a/lib/features/gamification/services/gamification_service.dart b/lib/features/gamification/services/gamification_service.dart index a75c309..5d68a51 100644 --- a/lib/features/gamification/services/gamification_service.dart +++ b/lib/features/gamification/services/gamification_service.dart @@ -28,14 +28,20 @@ class GamificationService { final profileJson = res['profile'] as Map? ?? {}; final rawSubs = res['submissions'] as List? ?? []; + final rawAchievements = res['achievements'] as List? ?? []; final submissions = rawSubs .map((s) => SubmissionModel.fromJson(Map.from(s as Map))) .toList(); + final achievements = rawAchievements + .map((a) => AchievementBadge.fromJson(Map.from(a as Map))) + .toList(); + return DashboardResponse( profile: UserGamificationProfile.fromJson(profileJson), submissions: submissions, + achievements: achievements, ); } @@ -132,42 +138,25 @@ class GamificationService { } // --------------------------------------------------------------------------- - // Achievements - // TODO: wire to achievements API when available on Node.js server + // Achievements — sourced from dashboard API `achievements` array. + // Falls back to default badges if API doesn't return achievements yet. // --------------------------------------------------------------------------- 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, - ), - ]; + try { + final dashboard = await getDashboard(); + if (dashboard.achievements.isNotEmpty) return dashboard.achievements; + } catch (_) { + // Fall through to defaults + } + return _defaultBadges; } + + static const _defaultBadges = [ + AchievementBadge(id: 'badge-01', title: 'First Submission', description: 'Submitted your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0), + AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Reached Silver tier.', iconName: 'star', isUnlocked: false, progress: 0.0), + AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Reach Gold tier (500 EP).', iconName: 'emoji_events', isUnlocked: false, progress: 0.0), + AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Appear in the district leaderboard top 10.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0), + AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Submit 10 events with 3+ images.', iconName: 'photo_library', isUnlocked: false, progress: 0.0), + AchievementBadge(id: 'badge-06', title: 'Pioneer', description: 'One of the first 100 contributors.', iconName: 'verified', isUnlocked: false, progress: 0.0), + ]; } diff --git a/lib/screens/contribute_screen.dart b/lib/screens/contribute_screen.dart index 33b1faa..eb018d8 100644 --- a/lib/screens/contribute_screen.dart +++ b/lib/screens/contribute_screen.dart @@ -245,6 +245,27 @@ class _ContributeScreenState extends State ), ), ), + const SizedBox(height: 16), + + // GAM-002: 3-card EP stat row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + _epStatCard('Lifetime EP', '${profile?.lifetimeEp ?? 0}', Icons.star, const Color(0xFFF59E0B)), + const SizedBox(width: 8), + _epStatCard('Liquid EP', '${profile?.currentEp ?? 0}', Icons.bolt, const Color(0xFF3B82F6)), + const SizedBox(width: 8), + _epStatCard('Reward Pts', '${profile?.currentRp ?? 0}', Icons.redeem, const Color(0xFF10B981)), + ], + ), + ), + + const SizedBox(height: 16), + + // GAM-005: Tier roadmap + _buildTierRoadmap(lifetimeEp, tier), + const SizedBox(height: 24), // Vertical tab navigation @@ -1587,12 +1608,82 @@ class _ContributeScreenState extends State style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[500], fontSize: 11), ), ), + + // CTR-001/002: Your Submissions list + if (provider.submissions.isNotEmpty) ...[ + const SizedBox(height: 28), + Text('Your Submissions', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 12), + ...provider.submissions.map((sub) => _buildSubmissionCard(sub, theme)), + ], ], ), ), ); } + Widget _buildSubmissionCard(SubmissionModel sub, ThemeData theme) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor.withOpacity(0.2)), + ), + child: Row( + children: [ + // Thumbnail or placeholder + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: sub.images.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network(sub.images.first, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Icon(Icons.event, color: Colors.grey)), + ) + : const Icon(Icons.event, color: Colors.grey), + ), + const SizedBox(width: 12), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(sub.eventName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text('${sub.category} · ${sub.district}', style: TextStyle(color: Colors.grey[500], fontSize: 11)), + ], + ), + ), + const SizedBox(width: 8), + // Status chip + EP badge + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + statusChip(sub.status), + if (sub.epAwarded > 0) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFDBEAFE), + borderRadius: BorderRadius.circular(8), + ), + child: Text('+${sub.epAwarded} EP', style: const TextStyle(color: Color(0xFF2563EB), fontWeight: FontWeight.w700, fontSize: 11)), + ), + ], + ], + ), + ], + ), + ); + } + Widget _formCard(List children) { return RepaintBoundary( child: Container( @@ -2657,4 +2748,129 @@ class _ContributeScreenState extends State } } } + + // ───────────────────────────────────────────────────────────────────────── + // GAM-002: EP stat card helper (used in left panel + profile) + // ───────────────────────────────────────────────────────────────────────── + Widget _epStatCard(String label, String value, IconData icon, Color color) { + return Expanded( + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(height: 4), + Text(value, style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(color: Colors.white60, fontSize: 10), textAlign: TextAlign.center), + ], + ), + ), + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // GAM-005: Horizontal tier roadmap (Bronze → Diamond) + // ───────────────────────────────────────────────────────────────────────── + Widget _buildTierRoadmap(int lifetimeEp, ContributorTier currentTier) { + const tiers = ContributorTier.values; // BRONZE, SILVER, GOLD, PLATINUM, DIAMOND + const thresholds = [0, 100, 500, 1500, 5000]; + final overallProgress = (lifetimeEp / 5000).clamp(0.0, 1.0); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.06), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Tier Roadmap', style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w600)), + const SizedBox(height: 10), + Row( + children: List.generate(tiers.length, (i) { + final reached = currentTier.index >= i; + final color = _tierColors[tiers[i]] ?? Colors.grey; + return Expanded( + child: Column( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: reached ? color : Colors.white24, + border: Border.all(color: reached ? color : Colors.white30, width: 2), + ), + ), + const SizedBox(height: 4), + Text( + tierLabel(tiers[i]), + style: TextStyle(color: reached ? Colors.white : Colors.white38, fontSize: 9, fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + Text( + '${thresholds[i]}', + style: TextStyle(color: reached ? Colors.white54 : Colors.white24, fontSize: 8), + ), + ], + ), + ); + }), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: overallProgress, + minHeight: 4, + backgroundColor: Colors.white12, + valueColor: AlwaysStoppedAnimation(_tierColors[currentTier] ?? Colors.white), + ), + ), + ], + ), + ), + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // CTR-001: Status chip for submissions + // ───────────────────────────────────────────────────────────────────────── + static Widget statusChip(String status) { + Color bg; + Color fg; + switch (status.toUpperCase()) { + case 'APPROVED': + bg = const Color(0xFFDCFCE7); + fg = const Color(0xFF16A34A); + break; + case 'REJECTED': + bg = const Color(0xFFFEE2E2); + fg = const Color(0xFFDC2626); + break; + default: // PENDING + bg = const Color(0xFFFEF9C3); + fg = const Color(0xFFCA8A04); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + status.toUpperCase(), + style: TextStyle(color: fg, fontWeight: FontWeight.w700, fontSize: 11), + ), + ); + } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 74d9532..3720f32 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../core/auth/auth_guard.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.dart'; +import 'package:table_calendar/table_calendar.dart'; import '../features/events/models/event_models.dart'; import '../features/events/services/events_service.dart'; @@ -22,6 +23,7 @@ import 'package:provider/provider.dart'; import '../features/gamification/providers/gamification_provider.dart'; import '../features/notifications/widgets/notification_bell.dart'; import '../features/notifications/providers/notification_provider.dart'; +import '../widgets/skeleton_loader.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({Key? key}) : super(key: key); @@ -660,7 +662,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Future _onDateChipTap(String label) async { if (label == 'Date') { // Open custom calendar dialog - final picked = await _showCalendarDialog(); + final picked = await _showCalendarBottomSheet(); if (picked != null) { setState(() { _selectedCustomDate = picked; @@ -963,173 +965,141 @@ class _HomeScreenState extends State with SingleTickerProviderStateM return dates; } - /// Show a custom calendar dialog matching the app design. - Future _showCalendarDialog() { - DateTime viewMonth = _selectedCustomDate ?? DateTime.now(); - DateTime? selected = _selectedCustomDate; + /// Show a calendar bottom sheet with TableCalendar for date filtering. + Future _showCalendarBottomSheet() { + DateTime focusedDay = _selectedCustomDate ?? DateTime.now(); + DateTime? selectedDay = _selectedCustomDate; final eventDates = _eventDates; - return showDialog( + return showModalBottomSheet( context: context, - barrierColor: Colors.black54, + isScrollControlled: true, + backgroundColor: Colors.transparent, builder: (ctx) { - return StatefulBuilder(builder: (ctx, setDialogState) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); + return StatefulBuilder(builder: (ctx, setSheetState) { + final theme = Theme.of(ctx); + final isDark = theme.brightness == Brightness.dark; - // Calendar calculations - final firstDayOfMonth = DateTime(viewMonth.year, viewMonth.month, 1); - final daysInMonth = DateTime(viewMonth.year, viewMonth.month + 1, 0).day; - final startWeekday = firstDayOfMonth.weekday; // 1=Mon - - const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; - const dayHeaders = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; - - return Center( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 28), - padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 20, offset: const Offset(0, 8))], - ), - child: Material( - color: Colors.transparent, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Month navigation - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _calendarNavButton(Icons.chevron_left, () { - setDialogState(() { - viewMonth = DateTime(viewMonth.year, viewMonth.month - 1, 1); - }); - }), - Text( - '${months[viewMonth.month - 1]} ${viewMonth.year}', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E)), - ), - _calendarNavButton(Icons.chevron_right, () { - setDialogState(() { - viewMonth = DateTime(viewMonth.year, viewMonth.month + 1, 1); - }); - }), - ], + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), ), - const SizedBox(height: 20), + ), - // Day of week headers - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: dayHeaders.map((d) => SizedBox( - width: 36, - child: Center(child: Text(d, style: TextStyle(color: Colors.grey[500], fontWeight: FontWeight.w600, fontSize: 13))), - )).toList(), + // TableCalendar + TableCalendar( + firstDay: DateTime(2020), + lastDay: DateTime(2030), + focusedDay: focusedDay, + selectedDayPredicate: (day) => + selectedDay != null && isSameDay(day, selectedDay), + onDaySelected: (selected, focused) { + setSheetState(() { + selectedDay = selected; + focusedDay = focused; + }); + }, + onPageChanged: (focused) { + setSheetState(() => focusedDay = focused); + }, + eventLoader: (day) { + final normalized = DateTime(day.year, day.month, day.day); + return eventDates.contains(normalized) ? [true] : []; + }, + startingDayOfWeek: StartingDayOfWeek.monday, + headerStyle: HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + titleTextStyle: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : const Color(0xFF1A1A2E), + ), + leftChevronIcon: Icon(Icons.chevron_left, + color: isDark ? Colors.white70 : const Color(0xFF374151)), + rightChevronIcon: Icon(Icons.chevron_right, + color: isDark ? Colors.white70 : const Color(0xFF374151)), ), - const SizedBox(height: 12), - - // Calendar grid - ...List.generate(6, (weekRow) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: List.generate(7, (weekCol) { - final cellIndex = weekRow * 7 + weekCol; - final dayNum = cellIndex - (startWeekday - 1) + 1; - - if (dayNum < 1 || dayNum > daysInMonth) { - return const SizedBox(width: 36, height: 44); - } - - final cellDate = DateTime(viewMonth.year, viewMonth.month, dayNum); - final isToday = cellDate == today; - final isSelected = selected != null && - cellDate.year == selected!.year && - cellDate.month == selected!.month && - cellDate.day == selected!.day; - final hasEvent = eventDates.contains(cellDate); - - return GestureDetector( - onTap: () { - setDialogState(() => selected = cellDate); - }, - child: SizedBox( - width: 36, - height: 44, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isSelected - ? const Color(0xFF2563EB) - : Colors.transparent, - shape: BoxShape.circle, - border: isToday && !isSelected - ? Border.all(color: const Color(0xFF2563EB), width: 1.5) - : null, - ), - child: Center( - child: Text( - '$dayNum', - style: TextStyle( - fontSize: 15, - fontWeight: (isToday || isSelected) ? FontWeight.w700 : FontWeight.w500, - color: isSelected - ? Colors.white - : isToday - ? const Color(0xFF2563EB) - : const Color(0xFF374151), - ), - ), - ), - ), - // Event dot - if (hasEvent) - Container( - width: 5, - height: 5, - decoration: BoxDecoration( - color: isSelected ? Colors.white : const Color(0xFFEF4444), - shape: BoxShape.circle, - ), - ) - else - const SizedBox(height: 5), - ], - ), - ), - ); - }), - ), - ); - }), - - const SizedBox(height: 12), - - // Done button - SizedBox( - width: 160, - height: 48, - child: ElevatedButton( - onPressed: () => Navigator.of(ctx).pop(selected), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF2563EB), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), - elevation: 0, - ), - child: const Text('Done', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + calendarStyle: CalendarStyle( + selectedDecoration: const BoxDecoration( + color: Color(0xFF2563EB), + shape: BoxShape.circle, + ), + todayDecoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF2563EB), width: 1.5), + ), + todayTextStyle: const TextStyle( + color: Color(0xFF2563EB), + fontWeight: FontWeight.w700, + ), + defaultTextStyle: TextStyle( + color: isDark ? Colors.white : const Color(0xFF374151), + ), + weekendTextStyle: TextStyle( + color: isDark ? Colors.white70 : const Color(0xFF374151), + ), + outsideTextStyle: TextStyle( + color: isDark ? Colors.white24 : Colors.grey[400]!, + ), + markerDecoration: const BoxDecoration( + color: Color(0xFFEF4444), + shape: BoxShape.circle, + ), + markerSize: 5, + markersMaxCount: 1, + ), + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: TextStyle( + color: isDark ? Colors.white54 : Colors.grey[500], + fontWeight: FontWeight.w600, + fontSize: 13, + ), + weekendStyle: TextStyle( + color: isDark ? Colors.white54 : Colors.grey[500], + fontWeight: FontWeight.w600, + fontSize: 13, ), ), - ], - ), + ), + + const SizedBox(height: 16), + + // Done button + SizedBox( + width: 160, + height: 48, + child: ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(selectedDay), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2563EB), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24)), + elevation: 0, + ), + child: const Text('Done', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + ], ), ), ); @@ -1138,22 +1108,6 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ); } - Widget _calendarNavButton(IconData icon, VoidCallback onTap) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(20), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.grey[300]!), - ), - child: Icon(icon, color: const Color(0xFF374151), size: 22), - ), - ); - } - Widget _buildHomeContent() { return Container( decoration: const BoxDecoration( @@ -1268,32 +1222,38 @@ class _HomeScreenState extends State with SingleTickerProviderStateM : Column( children: [ RepaintBoundary( - child: SizedBox( - height: 320, - child: PageView.builder( - controller: _heroPageController, - onPageChanged: (page) { - _heroPageNotifier.value = page; - // 8s delay after manual swipe for full read time - _startAutoScroll(delay: const Duration(seconds: 8)); - }, - itemCount: _heroEvents.length, - itemBuilder: (context, index) { - // Scale animation: active card = 1.0, adjacent = 0.94 - return AnimatedBuilder( - animation: _heroPageController, - builder: (context, child) { - double scale = index == _heroPageNotifier.value ? 1.0 : 0.94; - if (_heroPageController.position.haveDimensions) { - scale = (1.0 - - (_heroPageController.page! - index).abs() * 0.06) - .clamp(0.94, 1.0); - } - return Transform.scale(scale: scale, child: child); - }, - child: _buildHeroEventImage(_heroEvents[index]), - ); - }, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanDown: (_) => _autoScrollTimer?.cancel(), + onPanEnd: (_) => _startAutoScroll(delay: const Duration(seconds: 3)), + onPanCancel: () => _startAutoScroll(delay: const Duration(seconds: 3)), + child: SizedBox( + height: 320, + child: PageView.builder( + controller: _heroPageController, + onPageChanged: (page) { + _heroPageNotifier.value = page; + // 8s delay after manual swipe for full read time + _startAutoScroll(delay: const Duration(seconds: 8)); + }, + itemCount: _heroEvents.length, + itemBuilder: (context, index) { + // Scale animation: active card = 1.0, adjacent = 0.94 + return AnimatedBuilder( + animation: _heroPageController, + builder: (context, child) { + double scale = index == _heroPageNotifier.value ? 1.0 : 0.94; + if (_heroPageController.position.haveDimensions) { + scale = (1.0 - + (_heroPageController.page! - index).abs() * 0.06) + .clamp(0.94, 1.0); + } + return Transform.scale(scale: scale, child: child); + }, + child: _buildHeroEventImage(_heroEvents[index]), + ); + }, + ), ), ), ), @@ -1566,7 +1526,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM SizedBox( height: 200, child: _allFilteredByDate.isEmpty && _loading - ? const Center(child: CircularProgressIndicator()) + ? Row(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(right: 12), child: EventCardSkeleton()))) : _allFilteredByDate.isEmpty ? Center(child: Text( _selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found', @@ -1660,9 +1620,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM // Event sections — shelves (All) or filtered list (specific category) if (_loading) - const Padding( - padding: EdgeInsets.all(40), - child: Center(child: CircularProgressIndicator()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Column(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(bottom: 8), child: EventListSkeleton()))), ) else if (_allFilteredByDate.isEmpty && _selectedDateFilter.isNotEmpty) Padding( diff --git a/lib/screens/learn_more_screen.dart b/lib/screens/learn_more_screen.dart index 60f5ed8..8a7c4d7 100644 --- a/lib/screens/learn_more_screen.dart +++ b/lib/screens/learn_more_screen.dart @@ -15,6 +15,7 @@ import '../core/auth/auth_guard.dart'; import '../core/utils/error_utils.dart'; import '../core/constants.dart'; import '../features/reviews/widgets/review_section.dart'; +import 'checkout_screen.dart'; class LearnMoreScreen extends StatefulWidget { final int eventId; @@ -28,6 +29,18 @@ class LearnMoreScreen extends StatefulWidget { class _LearnMoreScreenState extends State { final EventsService _service = EventsService(); + void _navigateToCheckout() { + if (!AuthGuard.requireLogin(context, reason: 'Sign in to book this event.')) return; + if (_event == null) return; + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => CheckoutScreen( + eventId: _event!.id, + eventName: _event!.name, + eventImage: _event!.thumbImg, + ), + )); + } + bool _loading = true; EventModel? _event; String? _error; @@ -388,9 +401,7 @@ class _LearnMoreScreenState extends State { right: 32, bottom: 36, child: ElevatedButton( - onPressed: () { - // TODO: implement booking action - }, + onPressed: _navigateToCheckout, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1A56DB), foregroundColor: Colors.white, @@ -530,6 +541,42 @@ class _LearnMoreScreenState extends State { // ── MOBILE layout ───────────────────────────────────────────────────── return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, + bottomNavigationBar: (_event != null && _event!.isBookable) + ? Container( + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: SafeArea( + top: false, + child: SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _navigateToCheckout, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + child: const Text( + 'Book Now', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ), + ), + ) + : null, body: Stack( children: [ // ── Scrollable content (carousel + card scroll together) ── diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 4207b07..706e626 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -595,6 +595,13 @@ class _RegisterScreenState extends State { final AuthService _auth = AuthService(); bool _loading = false; + String? _selectedDistrict; + + static const _districts = [ + 'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha', + 'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad', + 'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod', + ]; @override void dispose() { @@ -625,6 +632,7 @@ class _RegisterScreenState extends State { email: email, phoneNumber: phone, password: pass, + district: _selectedDistrict, ); if (!mounted) return; @@ -679,6 +687,13 @@ class _RegisterScreenState extends State { const SizedBox(height: 8), TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone), const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedDistrict, + decoration: const InputDecoration(labelText: 'District (optional)'), + items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(), + onChanged: (v) => setState(() => _selectedDistrict = v), + ), + const SizedBox(height: 8), TextFormField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), validator: _passwordValidator), const SizedBox(height: 8), TextFormField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password'), validator: _passwordValidator), diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 28676b4..b4ae914 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -7,8 +7,12 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:provider/provider.dart'; import '../features/events/services/events_service.dart'; import '../features/events/models/event_models.dart'; +import '../features/gamification/providers/gamification_provider.dart'; +import '../features/gamification/models/gamification_models.dart'; +import '../widgets/skeleton_loader.dart'; import 'learn_more_screen.dart'; import 'settings_screen.dart'; import '../core/app_decoration.dart'; @@ -73,6 +77,11 @@ class _ProfileScreenState extends State _loadProfile(); _startAnimations(); + + // Load gamification data for profile EP cards + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().loadAll(); + }); } @override @@ -1588,6 +1597,9 @@ class _ProfileScreenState extends State ), ), + // PROF-003: Gamification stat cards + SliverToBoxAdapter(child: _buildGamificationCards(theme)), + // ── Ongoing Events ── if (_ongoingEvents.isNotEmpty) ...[ SliverToBoxAdapter(child: sectionTitle('Ongoing Events')), @@ -1657,4 +1669,54 @@ class _ProfileScreenState extends State ), ); } + + // ───────────────────────────────────────────────────────────────────────── + // PROF-003: Gamification 3-card row (Lifetime EP / Liquid EP / RP) + // ───────────────────────────────────────────────────────────────────────── + Widget _buildGamificationCards(ThemeData theme) { + return Consumer( + builder: (context, gp, _) { + if (gp.isLoading && gp.profile == null) { + return const ProfileStatsSkeleton(); + } + if (gp.profile == null) return const SizedBox.shrink(); + + final p = gp.profile!; + return Padding( + padding: const EdgeInsets.fromLTRB(18, 8, 18, 8), + child: Row( + children: [ + _gamStatCard('Lifetime EP', '${p.lifetimeEp}', Icons.star, const Color(0xFFF59E0B), theme), + const SizedBox(width: 10), + _gamStatCard('Liquid EP', '${p.currentEp}', Icons.bolt, const Color(0xFF3B82F6), theme), + const SizedBox(width: 10), + _gamStatCard('Reward Pts', '${p.currentRp}', Icons.redeem, const Color(0xFF10B981), theme), + ], + ), + ); + }, + ); + } + + Widget _gamStatCard(String label, String value, IconData icon, Color color, ThemeData theme) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.2)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 22), + const SizedBox(height: 4), + Text(value, style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16, color: theme.textTheme.bodyLarge?.color)), + const SizedBox(height: 2), + Text(label, style: TextStyle(color: theme.hintColor, fontSize: 10), textAlign: TextAlign.center), + ], + ), + ), + ); + } } diff --git a/lib/widgets/skeleton_loader.dart b/lib/widgets/skeleton_loader.dart new file mode 100644 index 0000000..6d47214 --- /dev/null +++ b/lib/widgets/skeleton_loader.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +/// Generic shimmer rectangle with configurable dimensions and border radius. +class SkeletonBox extends StatelessWidget { + final double width; + final double height; + final double borderRadius; + + const SkeletonBox({ + Key? key, + this.width = double.infinity, + required this.height, + this.borderRadius = 8, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Shimmer.fromColors( + baseColor: isDark ? const Color(0xFF2D2D2D) : Colors.grey[300]!, + highlightColor: isDark ? const Color(0xFF3D3D3D) : Colors.grey[100]!, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + ); + } +} + +/// Shimmer placeholder for a compact event card (used in horizontal lists). +class EventCardSkeleton extends StatelessWidget { + const EventCardSkeleton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonBox(height: 140, borderRadius: 12), + SizedBox(height: 8), + SkeletonBox(height: 14, width: 160), + SizedBox(height: 6), + SkeletonBox(height: 12, width: 100), + ], + ), + ); + } +} + +/// Shimmer placeholder for a full-width event list row. +class EventListSkeleton extends StatelessWidget { + const EventListSkeleton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Row( + children: const [ + SkeletonBox(width: 64, height: 64, borderRadius: 10), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox(height: 14), + SizedBox(height: 8), + SkeletonBox(height: 12, width: 140), + ], + ), + ), + ], + ), + ); + } +} + +/// Shimmer placeholder for hero carousel area. +class HeroCarouselSkeleton extends StatelessWidget { + const HeroCarouselSkeleton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: SkeletonBox(height: 320, borderRadius: 24), + ); + } +} + +/// Shimmer grid for achievements tab. +class AchievementGridSkeleton extends StatelessWidget { + const AchievementGridSkeleton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + padding: const EdgeInsets.all(16), + children: List.generate(4, (_) => const SkeletonBox(height: 160, borderRadius: 16)), + ); + } +} + +/// Shimmer placeholder for profile stat cards row. +class ProfileStatsSkeleton extends StatelessWidget { + const ProfileStatsSkeleton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: List.generate(3, (_) => const Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: SkeletonBox(height: 80, borderRadius: 12), + ), + )), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 53385eb..46d3c3a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -824,6 +824,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" simple_gesture_detector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ae373eb..7ff5f11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: razorpay_flutter: ^1.3.7 google_sign_in: ^6.2.2 http: ^1.2.0 + shimmer: ^3.0.0 dev_dependencies: flutter_test: