diff --git a/lib/core/api/api_endpoints.dart b/lib/core/api/api_endpoints.dart index 67d8387..3b46e58 100644 --- a/lib/core/api/api_endpoints.dart +++ b/lib/core/api/api_endpoints.dart @@ -35,11 +35,28 @@ class ApiEndpoints { static const String reviewHelpful = "$_reviewBase/helpful"; static const String reviewFlag = "$_reviewBase/flag"; - // 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/ + // Node.js gamification server (same host as reviews) + static const String _nodeBase = "https://app.eventifyplus.com/api"; + + // Gamification / Contributor Module + static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard"; + static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard"; + static const String shopItems = "$_nodeBase/v1/shop/items"; + static const String shopRedeem = "$_nodeBase/v1/shop/redeem"; + static const String contributeSubmit = "$_nodeBase/v1/gamification/submit-event"; + static const String gradeContribution = "$_nodeBase/v1/admin/contributions/"; // append {id}/grade/ + + // Bookings + static const String ticketMetaList = "$baseUrl/bookings/ticket-meta/list/"; + static const String cartAdd = "$baseUrl/bookings/cart/add/"; + static const String checkout = "$baseUrl/bookings/checkout/"; + static const String checkIn = "$baseUrl/bookings/check-in/"; + + // Auth - Google OAuth + static const String googleLogin = "$baseUrl/user/google-login/"; + + // Notifications + static const String notificationList = "$baseUrl/notifications/list/"; + static const String notificationMarkRead = "$baseUrl/notifications/mark-read/"; + static const String notificationCount = "$baseUrl/notifications/count/"; } diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart index 2da3e82..30fe3ff 100644 --- a/lib/features/auth/providers/auth_provider.dart +++ b/lib/features/auth/providers/auth_provider.dart @@ -59,6 +59,19 @@ class AuthProvider extends ChangeNotifier { } } + /// Google OAuth login. + Future googleLogin() async { + _loading = true; + notifyListeners(); + try { + final user = await _authService.googleLogin(); + _user = user; + } finally { + _loading = false; + notifyListeners(); + } + } + Future logout() async { await _authService.logout(); _user = null; diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart index 30c8727..59c562d 100644 --- a/lib/features/auth/services/auth_service.dart +++ b/lib/features/auth/services/auth_service.dart @@ -1,10 +1,12 @@ // lib/features/auth/services/auth_service.dart import 'package:flutter/foundation.dart' show kDebugMode, debugPrint; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:google_sign_in/google_sign_in.dart'; import '../../../core/api/api_client.dart'; import '../../../core/api/api_endpoints.dart'; import '../../../core/auth/auth_guard.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/analytics/posthog_service.dart'; import '../models/user_model.dart'; class AuthService { @@ -58,6 +60,12 @@ class AuthService { // Save phone if provided (optional) if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString()); + PostHogService.instance.identify(savedEmail, properties: { + 'username': displayCandidate, + 'login_method': 'email', + }); + PostHogService.instance.capture('user_logged_in'); + return UserModel.fromJson(res); } catch (e) { if (kDebugMode) debugPrint('AuthService.login error: $e'); @@ -130,6 +138,54 @@ class AuthService { } } + /// GOOGLE OAUTH LOGIN → returns UserModel + Future googleLogin() async { + try { + final googleSignIn = GoogleSignIn(scopes: ['email']); + final account = await googleSignIn.signIn(); + if (account == null) throw Exception('Google sign-in cancelled'); + + final auth = await account.authentication; + final idToken = auth.idToken; + if (idToken == null) throw Exception('Failed to get Google ID token'); + + final res = await _api.post( + ApiEndpoints.googleLogin, + body: {'id_token': idToken}, + requiresAuth: false, + ); + + final token = res['token']; + if (token == null) throw Exception('Token missing from response'); + + final serverEmail = (res['email'] as String?) ?? account.email; + final displayName = (res['username'] as String?) ?? account.displayName ?? serverEmail; + + AuthGuard.setGuest(false); + await TokenStorage.saveToken(token.toString(), serverEmail); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('current_email', serverEmail); + await prefs.setString('email', serverEmail); + final perKey = 'display_name_$serverEmail'; + if ((prefs.getString(perKey) ?? '').isEmpty) { + await prefs.setString(perKey, displayName); + } + if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString()); + + PostHogService.instance.identify(serverEmail, properties: { + 'username': displayName, + 'login_method': 'google', + }); + PostHogService.instance.capture('user_logged_in'); + + return UserModel.fromJson(res); + } catch (e) { + if (kDebugMode) debugPrint('AuthService.googleLogin error: $e'); + rethrow; + } + } + /// Logout – clear auth token and current_email (keep per-account display_name entries so they persist) Future logout() async { try { @@ -141,6 +197,8 @@ class AuthService { // Also remove canonical 'email' pointing to current user await prefs.remove('email'); // Do not delete display_name_ entries — they are per-account and should remain on device. + PostHogService.instance.capture('user_logged_out'); + PostHogService.instance.reset(); } catch (e) { if (kDebugMode) debugPrint('AuthService.logout warning: $e'); } diff --git a/lib/features/booking/models/booking_models.dart b/lib/features/booking/models/booking_models.dart new file mode 100644 index 0000000..b1c4c9b --- /dev/null +++ b/lib/features/booking/models/booking_models.dart @@ -0,0 +1,87 @@ +// lib/features/booking/models/booking_models.dart + +class TicketMetaModel { + final int id; + final int eventId; + final String ticketType; + final double price; + final int availableQuantity; + final String? description; + + const TicketMetaModel({ + required this.id, + required this.eventId, + required this.ticketType, + required this.price, + this.availableQuantity = 0, + this.description, + }); + + factory TicketMetaModel.fromJson(Map json) { + return TicketMetaModel( + id: (json['id'] as num?)?.toInt() ?? 0, + eventId: (json['event_id'] as num?)?.toInt() ?? (json['event'] as num?)?.toInt() ?? 0, + ticketType: json['ticket_type'] as String? ?? json['name'] as String? ?? '', + price: (json['price'] as num?)?.toDouble() ?? 0.0, + availableQuantity: (json['available_quantity'] as num?)?.toInt() ?? 0, + description: json['description'] as String?, + ); + } +} + +class CartItemModel { + final TicketMetaModel ticket; + int quantity; + + CartItemModel({required this.ticket, this.quantity = 1}); + + double get subtotal => ticket.price * quantity; +} + +class ShippingDetails { + final String name; + final String email; + final String phone; + final String? address; + final String? city; + final String? state; + final String? zipCode; + + const ShippingDetails({ + required this.name, + required this.email, + required this.phone, + this.address, + this.city, + this.state, + this.zipCode, + }); + + Map toJson() => { + 'name': name, + 'email': email, + 'phone': phone, + if (address != null) 'address': address, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (zipCode != null) 'zip_code': zipCode, + }; +} + +class OrderSummary { + final List items; + final double subtotal; + final double discount; + final double tax; + final double total; + final String? couponCode; + + const OrderSummary({ + required this.items, + required this.subtotal, + this.discount = 0, + this.tax = 0, + required this.total, + this.couponCode, + }); +} diff --git a/lib/features/booking/providers/checkout_provider.dart b/lib/features/booking/providers/checkout_provider.dart new file mode 100644 index 0000000..685ce95 --- /dev/null +++ b/lib/features/booking/providers/checkout_provider.dart @@ -0,0 +1,147 @@ +// lib/features/booking/providers/checkout_provider.dart + +import 'package:flutter/foundation.dart'; +import '../../../core/utils/error_utils.dart'; +import '../models/booking_models.dart'; +import '../services/booking_service.dart'; + +enum CheckoutStep { tickets, details, payment, confirmation } + +class CheckoutProvider extends ChangeNotifier { + final BookingService _service = BookingService(); + + // Event being booked + int? eventId; + String eventName = ''; + + // Step tracking + CheckoutStep currentStep = CheckoutStep.tickets; + + // Ticket selection + List availableTickets = []; + List cart = []; + + // Shipping + ShippingDetails? shippingDetails; + + // Coupon + String? couponCode; + + // Status + bool loading = false; + String? error; + String? paymentId; + + /// Initialize checkout for an event. + Future initForEvent(int eventId, String eventName) async { + this.eventId = eventId; + this.eventName = eventName; + currentStep = CheckoutStep.tickets; + cart = []; + shippingDetails = null; + couponCode = null; + paymentId = null; + error = null; + loading = true; + notifyListeners(); + + try { + availableTickets = await _service.getTicketMeta(eventId); + } catch (e) { + error = userFriendlyError(e); + } finally { + loading = false; + notifyListeners(); + } + } + + /// Add or update cart item. + void setTicketQuantity(TicketMetaModel ticket, int qty) { + cart.removeWhere((c) => c.ticket.id == ticket.id); + if (qty > 0) { + cart.add(CartItemModel(ticket: ticket, quantity: qty)); + } + notifyListeners(); + } + + double get subtotal => cart.fold(0, (sum, item) => sum + item.subtotal); + double get total => subtotal; // expand with discount/tax later + + bool get hasItems => cart.isNotEmpty; + + /// Move to next step. + void nextStep() { + if (currentStep == CheckoutStep.tickets && hasItems) { + currentStep = CheckoutStep.details; + } else if (currentStep == CheckoutStep.details && shippingDetails != null) { + currentStep = CheckoutStep.payment; + } + notifyListeners(); + } + + /// Move to previous step. + void previousStep() { + if (currentStep == CheckoutStep.payment) { + currentStep = CheckoutStep.details; + } else if (currentStep == CheckoutStep.details) { + currentStep = CheckoutStep.tickets; + } + notifyListeners(); + } + + /// Set shipping details from form. + void setShipping(ShippingDetails details) { + shippingDetails = details; + notifyListeners(); + } + + /// Process checkout on backend. + Future> processCheckout() async { + loading = true; + error = null; + notifyListeners(); + + try { + final tickets = cart.map((c) => { + 'ticket_meta_id': c.ticket.id, + 'quantity': c.quantity, + }).toList(); + + final res = await _service.processCheckout( + eventId: eventId!, + tickets: tickets, + shippingDetails: shippingDetails?.toJson() ?? {}, + couponCode: couponCode, + ); + return res; + } catch (e) { + error = userFriendlyError(e); + rethrow; + } finally { + loading = false; + notifyListeners(); + } + } + + /// Mark payment as complete. + void markPaymentSuccess(String id) { + paymentId = id; + currentStep = CheckoutStep.confirmation; + notifyListeners(); + } + + /// Reset checkout state. + void reset() { + eventId = null; + eventName = ''; + currentStep = CheckoutStep.tickets; + availableTickets = []; + cart = []; + shippingDetails = null; + couponCode = null; + paymentId = null; + error = null; + loading = false; + notifyListeners(); + } +} diff --git a/lib/features/booking/services/booking_service.dart b/lib/features/booking/services/booking_service.dart new file mode 100644 index 0000000..6b4b4e7 --- /dev/null +++ b/lib/features/booking/services/booking_service.dart @@ -0,0 +1,53 @@ +// lib/features/booking/services/booking_service.dart + +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_endpoints.dart'; +import '../models/booking_models.dart'; + +class BookingService { + final ApiClient _api = ApiClient(); + + /// Fetch available ticket types for an event. + Future> getTicketMeta(int eventId) async { + final res = await _api.post( + ApiEndpoints.ticketMetaList, + body: {'event_id': eventId}, + ); + final rawList = res['ticket_metas'] ?? res['tickets'] ?? res['data'] ?? []; + if (rawList is List) { + return rawList + .map((e) => TicketMetaModel.fromJson(Map.from(e as Map))) + .toList(); + } + return []; + } + + /// Add item to cart. + Future> addToCart({ + required int ticketMetaId, + required int quantity, + }) async { + return await _api.post( + ApiEndpoints.cartAdd, + body: {'ticket_meta_id': ticketMetaId, 'quantity': quantity}, + ); + } + + /// Process checkout — creates booking + returns order ID for payment. + Future> processCheckout({ + required int eventId, + required List> tickets, + required Map shippingDetails, + String? couponCode, + }) async { + return await _api.post( + ApiEndpoints.checkout, + body: { + 'event_id': eventId, + 'tickets': tickets, + 'shipping': shippingDetails, + if (couponCode != null) 'coupon_code': couponCode, + }, + ); + } +} diff --git a/lib/features/booking/services/payment_service.dart b/lib/features/booking/services/payment_service.dart new file mode 100644 index 0000000..2d1c102 --- /dev/null +++ b/lib/features/booking/services/payment_service.dart @@ -0,0 +1,67 @@ +// lib/features/booking/services/payment_service.dart + +import 'package:razorpay_flutter/razorpay_flutter.dart'; +import 'package:flutter/foundation.dart'; + +typedef PaymentSuccessCallback = void Function(PaymentSuccessResponse response); +typedef PaymentErrorCallback = void Function(PaymentFailureResponse response); +typedef ExternalWalletCallback = void Function(ExternalWalletResponse response); + +class PaymentService { + late Razorpay _razorpay; + + // Razorpay test key — matches web app + static const String _testKey = 'rzp_test_S49PVZmqAVoWSH'; + + PaymentSuccessCallback? onSuccess; + PaymentErrorCallback? onError; + ExternalWalletCallback? onExternalWallet; + + void initialize({ + required PaymentSuccessCallback onSuccess, + required PaymentErrorCallback onError, + ExternalWalletCallback? onExternalWallet, + }) { + _razorpay = Razorpay(); + this.onSuccess = onSuccess; + this.onError = onError; + this.onExternalWallet = onExternalWallet; + + _razorpay.on(Razorpay.EVENT_PAYMENT_SUCCESS, _handleSuccess); + _razorpay.on(Razorpay.EVENT_PAYMENT_ERROR, _handleError); + _razorpay.on(Razorpay.EVENT_EXTERNAL_WALLET, _handleExternalWallet); + } + + void openPayment({ + required double amount, + required String email, + required String phone, + required String eventName, + String? orderId, + }) { + final options = { + 'key': _testKey, + 'amount': (amount * 100).toInt(), // paise + 'currency': 'INR', + 'name': 'Eventify', + 'description': 'Ticket: $eventName', + 'prefill': { + 'email': email, + 'contact': phone, + }, + 'theme': {'color': '#0B63D6'}, + }; + if (orderId != null) options['order_id'] = orderId; + + if (kDebugMode) debugPrint('PaymentService: opening Razorpay with amount=${amount * 100} paise'); + _razorpay.open(options); + } + + void _handleSuccess(PaymentSuccessResponse res) => onSuccess?.call(res); + void _handleError(PaymentFailureResponse res) => onError?.call(res); + void _handleExternalWallet(ExternalWalletResponse res) => onExternalWallet?.call(res); + + void dispose() { + _razorpay.clear(); + } +} diff --git a/lib/features/gamification/models/gamification_models.dart b/lib/features/gamification/models/gamification_models.dart index ee7710d..041947c 100644 --- a/lib/features/gamification/models/gamification_models.dart +++ b/lib/features/gamification/models/gamification_models.dart @@ -94,41 +94,162 @@ class UserGamificationProfile { } // --------------------------------------------------------------------------- -// LeaderboardEntry +// LeaderboardEntry — maps from Node.js API response fields // --------------------------------------------------------------------------- class LeaderboardEntry { final int rank; final String username; final String? avatarUrl; final int lifetimeEp; + final int monthlyPoints; final ContributorTier tier; final int eventsCount; final bool isCurrentUser; + final String? district; const LeaderboardEntry({ required this.rank, required this.username, this.avatarUrl, required this.lifetimeEp, + this.monthlyPoints = 0, required this.tier, required this.eventsCount, this.isCurrentUser = false, + this.district, }); factory LeaderboardEntry.fromJson(Map json) { - final ep = (json['lifetime_ep'] as int?) ?? 0; + // Node.js API returns 'points' for lifetime EP and 'name' for username + final ep = (json['points'] as num?)?.toInt() ?? (json['lifetime_ep'] as num?)?.toInt() ?? 0; + final tierStr = json['level'] as String? ?? json['tier'] as String?; return LeaderboardEntry( - rank: (json['rank'] as int?) ?? 0, - username: json['username'] as String? ?? '', + rank: (json['rank'] as num?)?.toInt() ?? 0, + username: json['name'] as String? ?? json['username'] as String? ?? '', avatarUrl: json['avatar_url'] as String?, lifetimeEp: ep, - tier: tierFromEp(ep), - eventsCount: (json['events_count'] as int?) ?? 0, + monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0, + tier: tierStr != null ? _tierFromString(tierStr) : tierFromEp(ep), + eventsCount: (json['eventsAdded'] as num?)?.toInt() ?? (json['events_count'] as num?)?.toInt() ?? 0, isCurrentUser: (json['is_current_user'] as bool?) ?? false, + district: json['district'] as String?, ); } } +/// Parse tier string from API (e.g. "Gold") to enum. +ContributorTier _tierFromString(String s) { + switch (s.toLowerCase()) { + case 'diamond': return ContributorTier.DIAMOND; + case 'platinum': return ContributorTier.PLATINUM; + case 'gold': return ContributorTier.GOLD; + case 'silver': return ContributorTier.SILVER; + default: return ContributorTier.BRONZE; + } +} + +// --------------------------------------------------------------------------- +// CurrentUserStats — from leaderboard API's currentUser field +// --------------------------------------------------------------------------- +class CurrentUserStats { + final int rank; + final int points; + final int monthlyPoints; + final String level; + final int rewardCycleDays; + final int eventsAdded; + final String? district; + + const CurrentUserStats({ + required this.rank, + required this.points, + this.monthlyPoints = 0, + required this.level, + this.rewardCycleDays = 0, + this.eventsAdded = 0, + this.district, + }); + + factory CurrentUserStats.fromJson(Map json) { + return CurrentUserStats( + rank: (json['rank'] as num?)?.toInt() ?? 0, + points: (json['points'] as num?)?.toInt() ?? 0, + monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0, + level: json['level'] as String? ?? 'Bronze', + rewardCycleDays: (json['rewardCycleDays'] as num?)?.toInt() ?? 0, + eventsAdded: (json['eventsAdded'] as num?)?.toInt() ?? 0, + district: json['district'] as String?, + ); + } +} + +// --------------------------------------------------------------------------- +// LeaderboardResponse — wraps the full leaderboard API response +// --------------------------------------------------------------------------- +class LeaderboardResponse { + final List entries; + final CurrentUserStats? currentUser; + final int totalParticipants; + + const LeaderboardResponse({ + required this.entries, + this.currentUser, + this.totalParticipants = 0, + }); +} + +// --------------------------------------------------------------------------- +// SubmissionModel — event submissions from dashboard API +// --------------------------------------------------------------------------- +class SubmissionModel { + final String id; + final String eventName; + final String category; + final String status; // PENDING, APPROVED, REJECTED + final String? district; + final int epAwarded; + final DateTime createdAt; + final List images; + + const SubmissionModel({ + required this.id, + required this.eventName, + this.category = '', + required this.status, + this.district, + this.epAwarded = 0, + required this.createdAt, + this.images = const [], + }); + + factory SubmissionModel.fromJson(Map json) { + final rawImages = json['images'] as List? ?? []; + return SubmissionModel( + id: (json['id'] ?? json['submission_id'] ?? '').toString(), + eventName: json['event_name'] as String? ?? '', + category: json['category'] as String? ?? '', + status: json['status'] as String? ?? 'PENDING', + district: json['district'] as String?, + epAwarded: (json['total_ep_awarded'] as num?)?.toInt() ?? (json['ep_awarded'] as num?)?.toInt() ?? 0, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), + images: rawImages.map((e) => e.toString()).toList(), + ); + } +} + +// --------------------------------------------------------------------------- +// DashboardResponse — wraps the full dashboard API response +// --------------------------------------------------------------------------- +class DashboardResponse { + final UserGamificationProfile profile; + final List submissions; + + const DashboardResponse({ + required this.profile, + this.submissions = const [], + }); +} + // --------------------------------------------------------------------------- // ShopItem — mirrors `RedeemShopItem` table // --------------------------------------------------------------------------- diff --git a/lib/features/gamification/providers/gamification_provider.dart b/lib/features/gamification/providers/gamification_provider.dart index d3d1d21..9406c37 100644 --- a/lib/features/gamification/providers/gamification_provider.dart +++ b/lib/features/gamification/providers/gamification_provider.dart @@ -13,6 +13,9 @@ class GamificationProvider extends ChangeNotifier { List leaderboard = []; List shopItems = []; List achievements = []; + List submissions = []; + CurrentUserStats? currentUserStats; + int totalParticipants = 0; // Leaderboard filters — matches web version String leaderboardDistrict = 'Overall Kerala'; @@ -31,15 +34,22 @@ class GamificationProvider extends ChangeNotifier { try { final results = await Future.wait([ - _service.getProfile(), + _service.getDashboard(), _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; + final dashboard = results[0] as DashboardResponse; + profile = dashboard.profile; + submissions = dashboard.submissions; + + final lbResponse = results[1] as LeaderboardResponse; + leaderboard = lbResponse.entries; + currentUserStats = lbResponse.currentUser; + totalParticipants = lbResponse.totalParticipants; + + shopItems = results[2] as List; achievements = results[3] as List; } catch (e) { error = userFriendlyError(e); @@ -57,7 +67,10 @@ class GamificationProvider extends ChangeNotifier { leaderboardDistrict = district; notifyListeners(); try { - leaderboard = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod); + final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod); + leaderboard = response.entries; + currentUserStats = response.currentUser; + totalParticipants = response.totalParticipants; } catch (e) { error = userFriendlyError(e); } @@ -72,7 +85,10 @@ class GamificationProvider extends ChangeNotifier { leaderboardTimePeriod = period; notifyListeners(); try { - leaderboard = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period); + final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period); + leaderboard = response.entries; + currentUserStats = response.currentUser; + totalParticipants = response.totalParticipants; } catch (e) { error = userFriendlyError(e); } diff --git a/lib/features/gamification/services/gamification_service.dart b/lib/features/gamification/services/gamification_service.dart index 19f1c80..a75c309 100644 --- a/lib/features/gamification/services/gamification_service.dart +++ b/lib/features/gamification/services/gamification_service.dart @@ -1,146 +1,139 @@ // 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. +// Real API service for the Contributor / Gamification module. +// Calls the Node.js gamification server at app.eventifyplus.com. -import 'dart:math'; +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_endpoints.dart'; +import '../../../core/storage/token_storage.dart'; import '../models/gamification_models.dart'; class GamificationService { + final ApiClient _api = ApiClient(); + + /// Helper: get current user's email for API calls. + Future _getUserEmail() async { + final email = await TokenStorage.getUsername(); + return email ?? ''; + } + // --------------------------------------------------------------------------- - // User Gamification Profile - // TODO: replace with ApiClient.get(ApiEndpoints.gamificationProfile) + // Dashboard (profile + submissions) + // GET /v1/gamification/dashboard?user_id={email} // --------------------------------------------------------------------------- - 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, + Future getDashboard() async { + final email = await _getUserEmail(); + final url = '${ApiEndpoints.gamificationDashboard}?user_id=$email'; + final res = await _api.post(url, requiresAuth: false); + + final profileJson = res['profile'] as Map? ?? {}; + final rawSubs = res['submissions'] as List? ?? []; + + final submissions = rawSubs + .map((s) => SubmissionModel.fromJson(Map.from(s as Map))) + .toList(); + + return DashboardResponse( + profile: UserGamificationProfile.fromJson(profileJson), + submissions: submissions, ); } + /// Convenience — returns just the profile (backward-compatible with provider). + Future getProfile() async { + final dashboard = await getDashboard(); + return dashboard.profile; + } + // --------------------------------------------------------------------------- // Leaderboard - // district: 'Overall Kerala' | 'Thiruvananthapuram' | 'Kollam' | ... - // timePeriod: 'all_time' | 'this_month' - // TODO: replace with ApiClient.get(ApiEndpoints.leaderboard, params: {'district': district, 'period': timePeriod}) + // GET /v1/gamification/leaderboard?period=all|month&district=X&user_id=Y&limit=50 // --------------------------------------------------------------------------- - Future> getLeaderboard({ + Future getLeaderboard({ required String district, required String timePeriod, }) async { - await Future.delayed(const Duration(milliseconds: 500)); + final email = await _getUserEmail(); - // 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', - ]; + // Map Flutter filter values to API params + final period = timePeriod == 'this_month' ? 'month' : 'all'; - final rng = Random(district.hashCode ^ timePeriod.hashCode); - final baseEp = timePeriod == 'this_month' ? 800 : 4500; + final params = { + 'period': period, + 'user_id': email, + 'limit': '50', + }; + if (district != 'Overall Kerala') { + params['district'] = district; + } - 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 + final query = Uri(queryParameters: params).query; + final url = '${ApiEndpoints.leaderboard}?$query'; + final res = await _api.post(url, requiresAuth: false); + + final rawList = res['leaderboard'] as List? ?? []; + final entries = rawList + .map((e) => LeaderboardEntry.fromJson(Map.from(e as Map))) + .toList(); + + CurrentUserStats? currentUser; + if (res['currentUser'] != null && res['currentUser'] is Map) { + currentUser = CurrentUserStats.fromJson( + Map.from(res['currentUser'] as Map), ); - }); + } - 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(), + return LeaderboardResponse( + entries: entries, + currentUser: currentUser, + totalParticipants: (res['totalParticipants'] as num?)?.toInt() ?? entries.length, ); } + // --------------------------------------------------------------------------- + // Shop Items + // GET /v1/shop/items + // --------------------------------------------------------------------------- + Future> getShopItems() async { + final res = await _api.post(ApiEndpoints.shopItems, requiresAuth: false); + final rawItems = res['items'] as List? ?? []; + return rawItems + .map((e) => ShopItem.fromJson(Map.from(e as Map))) + .toList(); + } + + // --------------------------------------------------------------------------- + // Redeem Item + // POST /v1/shop/redeem body: { user_id, item_id } + // --------------------------------------------------------------------------- + Future redeemItem(String itemId) async { + final email = await _getUserEmail(); + final res = await _api.post( + ApiEndpoints.shopRedeem, + body: {'user_id': email, 'item_id': itemId}, + requiresAuth: false, + ); + final voucher = res['voucher'] as Map? ?? res; + return RedemptionRecord.fromJson(Map.from(voucher)); + } + // --------------------------------------------------------------------------- // Submit Contribution - // TODO: replace with ApiClient.post(ApiEndpoints.contributeSubmit, body: data) + // POST /v1/gamification/submit-event body: event data // --------------------------------------------------------------------------- Future submitContribution(Map data) async { - await Future.delayed(const Duration(milliseconds: 800)); - // Mock always succeeds + final email = await _getUserEmail(); + final body = {'user_id': email, ...data}; + await _api.post( + ApiEndpoints.contributeSubmit, + body: body, + requiresAuth: false, + ); } // --------------------------------------------------------------------------- // Achievements + // TODO: wire to achievements API when available on Node.js server // --------------------------------------------------------------------------- Future> getAchievements() async { await Future.delayed(const Duration(milliseconds: 300)); diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart new file mode 100644 index 0000000..911c2a8 --- /dev/null +++ b/lib/features/notifications/models/notification_model.dart @@ -0,0 +1,33 @@ +// lib/features/notifications/models/notification_model.dart + +class NotificationModel { + final int id; + final String title; + final String message; + final String type; // event, promo, system, booking + bool isRead; + final DateTime createdAt; + final String? actionUrl; + + NotificationModel({ + required this.id, + required this.title, + required this.message, + this.type = 'system', + this.isRead = false, + required this.createdAt, + this.actionUrl, + }); + + factory NotificationModel.fromJson(Map json) { + return NotificationModel( + id: (json['id'] as num?)?.toInt() ?? 0, + title: json['title'] as String? ?? '', + message: json['message'] as String? ?? '', + type: json['notification_type'] as String? ?? json['type'] as String? ?? 'system', + isRead: (json['is_read'] as bool?) ?? false, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), + actionUrl: json['action_url'] as String?, + ); + } +} diff --git a/lib/features/notifications/providers/notification_provider.dart b/lib/features/notifications/providers/notification_provider.dart new file mode 100644 index 0000000..4381e61 --- /dev/null +++ b/lib/features/notifications/providers/notification_provider.dart @@ -0,0 +1,72 @@ +// lib/features/notifications/providers/notification_provider.dart + +import 'package:flutter/foundation.dart'; +import '../../../core/utils/error_utils.dart'; +import '../models/notification_model.dart'; +import '../services/notification_service.dart'; + +class NotificationProvider extends ChangeNotifier { + final NotificationService _service = NotificationService(); + + List notifications = []; + int unreadCount = 0; + bool loading = false; + String? error; + + /// Load full notification list. + Future loadNotifications() async { + loading = true; + error = null; + notifyListeners(); + try { + notifications = await _service.getNotifications(); + unreadCount = notifications.where((n) => !n.isRead).length; + } catch (e) { + error = userFriendlyError(e); + } finally { + loading = false; + notifyListeners(); + } + } + + /// Lightweight count refresh (no full list fetch). + Future refreshUnreadCount() async { + try { + unreadCount = await _service.getUnreadCount(); + notifyListeners(); + } catch (_) { + // Silently fail — badge just won't update + } + } + + /// Mark single notification as read. + Future markAsRead(int id) async { + try { + await _service.markAsRead(notificationId: id); + final idx = notifications.indexWhere((n) => n.id == id); + if (idx >= 0) { + notifications[idx].isRead = true; + unreadCount = notifications.where((n) => !n.isRead).length; + notifyListeners(); + } + } catch (e) { + error = userFriendlyError(e); + notifyListeners(); + } + } + + /// Mark all as read. + Future markAllAsRead() async { + try { + await _service.markAsRead(); // null = mark all + for (final n in notifications) { + n.isRead = true; + } + unreadCount = 0; + notifyListeners(); + } catch (e) { + error = userFriendlyError(e); + notifyListeners(); + } + } +} diff --git a/lib/features/notifications/services/notification_service.dart b/lib/features/notifications/services/notification_service.dart new file mode 100644 index 0000000..1104be0 --- /dev/null +++ b/lib/features/notifications/services/notification_service.dart @@ -0,0 +1,41 @@ +// lib/features/notifications/services/notification_service.dart + +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_endpoints.dart'; +import '../models/notification_model.dart'; + +class NotificationService { + final ApiClient _api = ApiClient(); + + /// Fetch notifications for current user (paginated). + Future> getNotifications({int page = 1, int pageSize = 20}) async { + final res = await _api.post( + ApiEndpoints.notificationList, + body: {'page': page, 'page_size': pageSize}, + ); + final rawList = res['notifications'] ?? res['data'] ?? []; + if (rawList is List) { + return rawList + .map((e) => NotificationModel.fromJson(Map.from(e as Map))) + .toList(); + } + return []; + } + + /// Mark a single notification as read, or all if [notificationId] is null. + Future markAsRead({int? notificationId}) async { + final body = {}; + if (notificationId != null) { + body['notification_id'] = notificationId; + } else { + body['mark_all'] = true; + } + await _api.post(ApiEndpoints.notificationMarkRead, body: body); + } + + /// Get unread notification count (lightweight). + Future getUnreadCount() async { + final res = await _api.post(ApiEndpoints.notificationCount); + return (res['unread_count'] as num?)?.toInt() ?? 0; + } +} diff --git a/lib/features/notifications/widgets/notification_bell.dart b/lib/features/notifications/widgets/notification_bell.dart new file mode 100644 index 0000000..61be25e --- /dev/null +++ b/lib/features/notifications/widgets/notification_bell.dart @@ -0,0 +1,61 @@ +// lib/features/notifications/widgets/notification_bell.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/notification_provider.dart'; +import 'notification_panel.dart'; + +class NotificationBell extends StatelessWidget { + const NotificationBell({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + return GestureDetector( + onTap: () => _showPanel(context), + child: Stack( + clipBehavior: Clip.none, + children: [ + const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.notifications_outlined, size: 26, color: Colors.black87), + ), + if (provider.unreadCount > 0) + Positioned( + right: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: Text( + provider.unreadCount > 99 ? '99+' : '${provider.unreadCount}', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + }, + ); + } + + void _showPanel(BuildContext context) { + context.read().loadNotifications(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => ChangeNotifierProvider.value( + value: context.read(), + child: const NotificationPanel(), + ), + ); + } +} diff --git a/lib/features/notifications/widgets/notification_panel.dart b/lib/features/notifications/widgets/notification_panel.dart new file mode 100644 index 0000000..16307d8 --- /dev/null +++ b/lib/features/notifications/widgets/notification_panel.dart @@ -0,0 +1,101 @@ +// lib/features/notifications/widgets/notification_panel.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/notification_provider.dart'; +import 'notification_tile.dart'; + +class NotificationPanel extends StatelessWidget { + const NotificationPanel({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + minChildSize: 0.35, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Handle bar + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 48, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Notifications', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + Consumer( + builder: (_, provider, __) { + if (provider.unreadCount == 0) return const SizedBox.shrink(); + return TextButton( + onPressed: provider.markAllAsRead, + child: const Text('Mark all read', style: TextStyle(fontSize: 13)), + ); + }, + ), + ], + ), + ), + const Divider(height: 1), + // List + Expanded( + child: Consumer( + builder: (_, provider, __) { + if (provider.loading) { + return const Center(child: CircularProgressIndicator()); + } + if (provider.notifications.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.notifications_none, size: 56, color: Colors.grey.shade300), + const SizedBox(height: 12), + Text('No notifications yet', style: TextStyle(color: Colors.grey.shade500, fontSize: 15)), + ], + ), + ); + } + return ListView.separated( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: provider.notifications.length, + separatorBuilder: (_, __) => const Divider(height: 1, indent: 72), + itemBuilder: (ctx, idx) { + final notif = provider.notifications[idx]; + return NotificationTile( + notification: notif, + onTap: () { + if (!notif.isRead) provider.markAsRead(notif.id); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/notifications/widgets/notification_tile.dart b/lib/features/notifications/widgets/notification_tile.dart new file mode 100644 index 0000000..0674fe0 --- /dev/null +++ b/lib/features/notifications/widgets/notification_tile.dart @@ -0,0 +1,94 @@ +// lib/features/notifications/widgets/notification_tile.dart + +import 'package:flutter/material.dart'; +import '../models/notification_model.dart'; + +class NotificationTile extends StatelessWidget { + final NotificationModel notification; + final VoidCallback? onTap; + + const NotificationTile({Key? key, required this.notification, this.onTap}) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + color: notification.isRead ? Colors.transparent : const Color(0xFFF0F4FF), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildIcon(), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notification.title, + style: TextStyle( + fontWeight: notification.isRead ? FontWeight.w400 : FontWeight.w600, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + notification.message, + style: TextStyle(color: Colors.grey.shade600, fontSize: 13), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + _timeAgo(notification.createdAt), + style: TextStyle(color: Colors.grey.shade400, fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildIcon() { + final config = _typeConfig(notification.type); + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: config.color.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon(config.icon, color: config.color, size: 20), + ); + } + + static _TypeConfig _typeConfig(String type) { + switch (type) { + case 'event': return _TypeConfig(Colors.blue, Icons.event); + case 'promo': return _TypeConfig(Colors.green, Icons.local_offer); + case 'booking': return _TypeConfig(Colors.orange, Icons.confirmation_number); + default: return _TypeConfig(Colors.grey, Icons.info_outline); + } + } + + static String _timeAgo(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${dt.day}/${dt.month}/${dt.year}'; + } +} + +class _TypeConfig { + final Color color; + final IconData icon; + const _TypeConfig(this.color, this.icon); +} diff --git a/lib/main.dart b/lib/main.dart index f61ae3a..e11522e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,17 +2,24 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:provider/provider.dart'; import 'screens/home_screen.dart'; import 'screens/home_desktop_screen.dart'; import 'screens/login_screen.dart'; import 'screens/desktop_login_screen.dart'; -import 'screens/responsive_layout.dart'; // keep this path if your file is under lib/screens/ +import 'screens/responsive_layout.dart'; import 'core/theme_manager.dart'; +import 'core/analytics/posthog_service.dart'; +import 'features/auth/providers/auth_provider.dart'; +import 'features/gamification/providers/gamification_provider.dart'; +import 'features/booking/providers/checkout_provider.dart'; +import 'features/notifications/providers/notification_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await ThemeManager.init(); // load saved theme preference + await PostHogService.instance.init(); // Increase image cache for smoother scrolling and faster re-renders PaintingBinding.instance.imageCache.maximumSize = 500; @@ -90,18 +97,26 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: ThemeManager.themeMode, - builder: (context, mode, _) { - return MaterialApp( - title: 'Event App', - debugShowCheckedModeBanner: false, - theme: _lightTheme(), - darkTheme: _darkTheme(), - themeMode: mode, - home: const StartupScreen(), - ); - }, + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProvider(create: (_) => GamificationProvider()), + ChangeNotifierProvider(create: (_) => CheckoutProvider()), + ChangeNotifierProvider(create: (_) => NotificationProvider()), + ], + child: ValueListenableBuilder( + valueListenable: ThemeManager.themeMode, + builder: (context, mode, _) { + return MaterialApp( + title: 'Event App', + debugShowCheckedModeBanner: false, + theme: _lightTheme(), + darkTheme: _darkTheme(), + themeMode: mode, + home: const StartupScreen(), + ); + }, + ), ); } } diff --git a/lib/screens/booking_screen.dart b/lib/screens/booking_screen.dart index ff4a1fc..58b8f84 100644 --- a/lib/screens/booking_screen.dart +++ b/lib/screens/booking_screen.dart @@ -1,15 +1,19 @@ // lib/screens/booking_screen.dart import 'package:flutter/material.dart'; +import 'checkout_screen.dart'; class BookingScreen extends StatefulWidget { - // Keep onBook in the constructor if you want to use it later, but we won't call it here. final VoidCallback? onBook; final String image; + final int? eventId; + final String? eventName; const BookingScreen({ Key? key, this.onBook, this.image = 'assets/images/event1.jpg', + this.eventId, + this.eventName, }) : super(key: key); @override @@ -39,11 +43,22 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni bool _booked = false; void _performLocalBooking() { - // mark locally booked (do NOT call widget.onBook()) + // If event data is available, navigate to real checkout + if (widget.eventId != null) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => CheckoutScreen( + eventId: widget.eventId!, + eventName: widget.eventName ?? 'Event', + eventImage: widget.image, + ), + )); + return; + } + // Fallback: demo booking for events without IDs if (!_booked) { setState(() => _booked = true); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Tickets booked (demo)')), + const SnackBar(content: Text('Tickets booked (demo)')), ); } } diff --git a/lib/screens/checkout_screen.dart b/lib/screens/checkout_screen.dart new file mode 100644 index 0000000..2add183 --- /dev/null +++ b/lib/screens/checkout_screen.dart @@ -0,0 +1,392 @@ +// lib/screens/checkout_screen.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../features/booking/providers/checkout_provider.dart'; +import '../features/booking/services/payment_service.dart'; +import '../features/booking/models/booking_models.dart'; +import '../core/utils/error_utils.dart'; +import 'tickets_booked_screen.dart'; + +class CheckoutScreen extends StatefulWidget { + final int eventId; + final String eventName; + final String? eventImage; + + const CheckoutScreen({ + Key? key, + required this.eventId, + required this.eventName, + this.eventImage, + }) : super(key: key); + + @override + State createState() => _CheckoutScreenState(); +} + +class _CheckoutScreenState extends State { + late final PaymentService _paymentService; + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + _paymentService = PaymentService(); + _paymentService.initialize( + onSuccess: _onPaymentSuccess, + onError: _onPaymentError, + ); + _prefillUserData(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initForEvent(widget.eventId, widget.eventName); + }); + } + + Future _prefillUserData() async { + final prefs = await SharedPreferences.getInstance(); + _emailCtrl.text = prefs.getString('email') ?? ''; + _nameCtrl.text = prefs.getString('display_name') ?? ''; + _phoneCtrl.text = prefs.getString('phone_number') ?? ''; + } + + void _onPaymentSuccess(dynamic response) { + final provider = context.read(); + provider.markPaymentSuccess(response.paymentId ?? 'success'); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const TicketsBookedScreen()), + ); + } + + void _onPaymentError(dynamic response) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Payment failed: ${response.message ?? "Please try again"}'), + backgroundColor: Colors.red, + ), + ); + } + + @override + void dispose() { + _paymentService.dispose(); + _nameCtrl.dispose(); + _emailCtrl.dispose(); + _phoneCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Checkout', style: TextStyle(fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0, + ), + body: Consumer( + builder: (ctx, provider, _) { + if (provider.loading && provider.availableTickets.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (provider.error != null && provider.availableTickets.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(provider.error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => provider.initForEvent(widget.eventId, widget.eventName), + child: const Text('Retry'), + ), + ], + ), + ); + } + return Column( + children: [ + _buildStepIndicator(provider), + Expanded(child: _buildCurrentStep(provider)), + _buildBottomBar(provider), + ], + ); + }, + ), + ); + } + + Widget _buildStepIndicator(CheckoutProvider provider) { + final steps = ['Tickets', 'Details', 'Payment']; + final currentIdx = provider.currentStep.index; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + color: Colors.white, + child: Row( + children: List.generate(steps.length, (i) { + final isActive = i <= currentIdx; + return Expanded( + child: Row( + children: [ + Container( + width: 28, height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? const Color(0xFF0B63D6) : Colors.grey.shade300, + ), + child: Center( + child: Text('${i + 1}', style: TextStyle( + color: isActive ? Colors.white : Colors.grey, + fontSize: 13, fontWeight: FontWeight.w600, + )), + ), + ), + const SizedBox(width: 6), + Text(steps[i], style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + color: isActive ? Colors.black : Colors.grey, + )), + if (i < steps.length - 1) Expanded( + child: Container( + height: 1, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: i < currentIdx ? const Color(0xFF0B63D6) : Colors.grey.shade300, + ), + ), + ], + ), + ); + }), + ), + ); + } + + Widget _buildCurrentStep(CheckoutProvider provider) { + switch (provider.currentStep) { + case CheckoutStep.tickets: + return _buildTicketSelection(provider); + case CheckoutStep.details: + return _buildDetailsForm(provider); + case CheckoutStep.payment: + return _buildPaymentReview(provider); + case CheckoutStep.confirmation: + return const Center(child: Text('Booking confirmed!')); + } + } + + Widget _buildTicketSelection(CheckoutProvider provider) { + if (provider.availableTickets.isEmpty) { + return const Center(child: Text('No tickets available for this event.')); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(widget.eventName, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + const SizedBox(height: 20), + ...provider.availableTickets.map((ticket) { + final cartMatches = provider.cart.where((c) => c.ticket.id == ticket.id); + final cartItem = cartMatches.isNotEmpty ? cartMatches.first : null; + final qty = cartItem?.quantity ?? 0; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: qty > 0 ? const Color(0xFF0B63D6) : Colors.grey.shade200), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ticket.ticketType, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + const SizedBox(height: 4), + Text('Rs ${ticket.price.toStringAsFixed(0)}', style: const TextStyle(color: Color(0xFF0B63D6), fontWeight: FontWeight.w700, fontSize: 18)), + if (ticket.description != null) ...[ + const SizedBox(height: 4), + Text(ticket.description!, style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + ], + ], + ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: qty > 0 ? () => provider.setTicketQuantity(ticket, qty - 1) : null, + ), + Text('$qty', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + IconButton( + icon: const Icon(Icons.add_circle_outline, color: Color(0xFF0B63D6)), + onPressed: qty < ticket.availableQuantity + ? () => provider.setTicketQuantity(ticket, qty + 1) + : null, + ), + ], + ), + ], + ), + ); + }), + ], + ); + } + + Widget _buildDetailsForm(CheckoutProvider provider) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Contact Details', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const SizedBox(height: 16), + _field('Full Name', _nameCtrl, validator: (v) => v!.isEmpty ? 'Required' : null), + _field('Email', _emailCtrl, type: TextInputType.emailAddress, validator: (v) => v!.isEmpty ? 'Required' : null), + _field('Phone', _phoneCtrl, type: TextInputType.phone, validator: (v) => v!.isEmpty ? 'Required' : null), + ], + ), + ), + ); + } + + Widget _field(String label, TextEditingController ctrl, {TextInputType? type, String? Function(String?)? validator}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: TextFormField( + controller: ctrl, + keyboardType: type, + validator: validator, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + filled: true, + fillColor: Colors.grey.shade50, + ), + ), + ); + } + + Widget _buildPaymentReview(CheckoutProvider provider) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Order Summary', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const SizedBox(height: 16), + ...provider.cart.map((item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${item.ticket.ticketType} x${item.quantity}'), + Text('Rs ${item.subtotal.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + )), + const Divider(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + Text('Rs ${provider.total.toStringAsFixed(0)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Color(0xFF0B63D6))), + ], + ), + const SizedBox(height: 24), + if (_nameCtrl.text.isNotEmpty) ...[ + Text('Name: ${_nameCtrl.text}', style: TextStyle(color: Colors.grey.shade700)), + Text('Email: ${_emailCtrl.text}', style: TextStyle(color: Colors.grey.shade700)), + Text('Phone: ${_phoneCtrl.text}', style: TextStyle(color: Colors.grey.shade700)), + ], + ], + ); + } + + Widget _buildBottomBar(CheckoutProvider provider) { + return Container( + padding: EdgeInsets.fromLTRB(16, 12, 16, MediaQuery.of(context).padding.bottom + 12), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 12, offset: const Offset(0, -4))], + ), + child: Row( + children: [ + if (provider.currentStep != CheckoutStep.tickets) + TextButton( + onPressed: provider.previousStep, + child: const Text('Back'), + ), + const Spacer(), + if (provider.currentStep == CheckoutStep.tickets) + ElevatedButton( + onPressed: provider.hasItems ? provider.nextStep : null, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0B63D6), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text('Continue Rs ${provider.subtotal.toStringAsFixed(0)}'), + ) + else if (provider.currentStep == CheckoutStep.details) + ElevatedButton( + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + provider.setShipping(ShippingDetails( + name: _nameCtrl.text, + email: _emailCtrl.text, + phone: _phoneCtrl.text, + )); + provider.nextStep(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0B63D6), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Review Order'), + ) + else if (provider.currentStep == CheckoutStep.payment) + ElevatedButton( + onPressed: provider.loading ? null : () => _processPayment(provider), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0B63D6), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: provider.loading + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : Text('Pay Rs ${provider.total.toStringAsFixed(0)}'), + ), + ], + ), + ); + } + + Future _processPayment(CheckoutProvider provider) async { + try { + await provider.processCheckout(); + _paymentService.openPayment( + amount: provider.total, + email: _emailCtrl.text, + phone: _phoneCtrl.text, + eventName: widget.eventName, + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(userFriendlyError(e)), backgroundColor: Colors.red), + ); + } + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a5e1e91..74d9532 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -20,6 +20,8 @@ import 'package:geocoding/geocoding.dart'; import 'package:flutter_svg/flutter_svg.dart'; 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'; class HomeScreen extends StatefulWidget { const HomeScreen({Key? key}) : super(key: key); @@ -126,6 +128,11 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e)))); } } + + // Refresh notification badge count (fire-and-forget) + if (mounted) { + context.read().refreshUnreadCount(); + } } Future _reverseGeocodeAndSave(double lat, double lng, SharedPreferences prefs) async { @@ -438,12 +445,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM children: [ RepaintBoundary(child: _buildHomeContent()), // index 0 const RepaintBoundary(child: CalendarScreen()), // index 1 - RepaintBoundary( - child: ChangeNotifierProvider( - create: (_) => GamificationProvider(), - child: const ContributeScreen(), - ), - ), // index 2 (full page, scrollable) + const RepaintBoundary(child: ContributeScreen()), // index 2 (full page, scrollable) const RepaintBoundary(child: ProfileScreen()), // index 3 ], ), @@ -1221,18 +1223,25 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ), ), - GestureDetector( - onTap: _openEventSearch, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - shape: BoxShape.circle, - border: Border.all(color: Colors.white.withOpacity(0.2)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const NotificationBell(), + const SizedBox(width: 8), + GestureDetector( + onTap: _openEventSearch, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withOpacity(0.2)), + ), + child: const Icon(Icons.search, color: Colors.white, size: 24), + ), ), - child: const Icon(Icons.search, color: Colors.white, size: 24), - ), + ], ), ], ), diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index fd820ea..4207b07 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -5,9 +5,13 @@ import '../core/utils/error_utils.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import 'package:provider/provider.dart'; import '../features/auth/services/auth_service.dart'; +import '../features/auth/providers/auth_provider.dart'; import '../core/auth/auth_guard.dart'; import 'home_screen.dart'; +import 'responsive_layout.dart'; +import 'home_desktop_screen.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({Key? key}) : super(key: key); @@ -124,6 +128,27 @@ class _LoginScreenState extends State { ); } + Future _performGoogleLogin() async { + try { + setState(() => _loading = true); + await Provider.of(context, listen: false).googleLogin(); + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => ResponsiveLayout(mobile: HomeScreen(), desktop: const HomeDesktopScreen())), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(userFriendlyError(e))), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + /// Glassmorphism pill-shaped input decoration InputDecoration _glassInputDecoration({ required String hint, @@ -474,7 +499,7 @@ class _LoginScreenState extends State { color: Color(0xFF4285F4), ), ), - onTap: _showComingSoon, + onTap: _performGoogleLogin, ), const SizedBox(width: 12), _socialButton( diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 12618bb..ff8b87b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import file_selector_macos import geolocator_apple +import google_sign_in_ios import path_provider_foundation import share_plus import shared_preferences_foundation @@ -17,6 +18,7 @@ import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 0bb2fa0..53385eb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + eventify: + dependency: transitive + description: + name: eventify + sha256: b829429f08586cc2001c628e7499e3e3c2493a1d895fd73b00ecb23351aa5a66 + url: "https://pub.dev" + source: hosted + version: "1.0.1" fake_async: dependency: transitive description: @@ -336,6 +344,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" google_maps: dependency: transitive description: @@ -384,6 +400,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.14+3" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" html: dependency: transitive description: @@ -393,7 +449,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 @@ -672,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + razorpay_flutter: + dependency: "direct main" + description: + name: razorpay_flutter + sha256: "8d985b769808cb6c8d3f2fbcc25f9ab78e29191965c31c98e2d69d55d9d20ff1" + url: "https://pub.dev" + source: hosted + version: "1.4.3" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0d2b0fa..ae373eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,9 @@ dependencies: provider: ^6.1.2 video_player: ^2.8.1 cached_network_image: ^3.3.1 + razorpay_flutter: ^1.3.7 + google_sign_in: ^6.2.2 + http: ^1.2.0 dev_dependencies: flutter_test: