feat: Phase 1 critical gaps — gamification API, Razorpay checkout, Google OAuth, notifications
- Fix gamification endpoints to use Node.js server (app.eventifyplus.com) - Replace 6 mock gamification methods with real API calls (dashboard, leaderboard, shop, redeem, submit) - Add booking models, service, payment service (Razorpay), checkout provider - Add 3-step CheckoutScreen with Razorpay native modal integration - Add Google OAuth login (Flutter + Django backend) - Add full notifications system (Django model + 3 endpoints + Flutter UI) - Register CheckoutProvider, NotificationProvider in main.dart MultiProvider - Wire notification bell in HomeScreen app bar - Add razorpay_flutter ^1.3.7 and google_sign_in ^6.2.2 packages
This commit is contained in:
@@ -35,11 +35,28 @@ class ApiEndpoints {
|
|||||||
static const String reviewHelpful = "$_reviewBase/helpful";
|
static const String reviewHelpful = "$_reviewBase/helpful";
|
||||||
static const String reviewFlag = "$_reviewBase/flag";
|
static const String reviewFlag = "$_reviewBase/flag";
|
||||||
|
|
||||||
// Gamification / Contributor Module (TechDocs v2)
|
// Node.js gamification server (same host as reviews)
|
||||||
static const String gamificationProfile = "$baseUrl/v1/user/gamification-profile/";
|
static const String _nodeBase = "https://app.eventifyplus.com/api";
|
||||||
static const String leaderboard = "$baseUrl/v1/leaderboard/";
|
|
||||||
static const String shopItems = "$baseUrl/v1/shop/items/";
|
// Gamification / Contributor Module
|
||||||
static const String shopRedeem = "$baseUrl/v1/shop/redeem/";
|
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
|
||||||
static const String contributeSubmit = "$baseUrl/v1/contributions/submit/";
|
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
|
||||||
static const String gradeContribution = "$baseUrl/v1/admin/contributions/"; // append {id}/grade/
|
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/";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Google OAuth login.
|
||||||
|
Future<void> googleLogin() async {
|
||||||
|
_loading = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final user = await _authService.googleLogin();
|
||||||
|
_user = user;
|
||||||
|
} finally {
|
||||||
|
_loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await _authService.logout();
|
await _authService.logout();
|
||||||
_user = null;
|
_user = null;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// lib/features/auth/services/auth_service.dart
|
// lib/features/auth/services/auth_service.dart
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
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_client.dart';
|
||||||
import '../../../core/api/api_endpoints.dart';
|
import '../../../core/api/api_endpoints.dart';
|
||||||
import '../../../core/auth/auth_guard.dart';
|
import '../../../core/auth/auth_guard.dart';
|
||||||
import '../../../core/storage/token_storage.dart';
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../../../core/analytics/posthog_service.dart';
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
@@ -58,6 +60,12 @@ class AuthService {
|
|||||||
// Save phone if provided (optional)
|
// Save phone if provided (optional)
|
||||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
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);
|
return UserModel.fromJson(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) debugPrint('AuthService.login error: $e');
|
if (kDebugMode) debugPrint('AuthService.login error: $e');
|
||||||
@@ -130,6 +138,54 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GOOGLE OAUTH LOGIN → returns UserModel
|
||||||
|
Future<UserModel> 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)
|
/// Logout – clear auth token and current_email (keep per-account display_name entries so they persist)
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
try {
|
||||||
@@ -141,6 +197,8 @@ class AuthService {
|
|||||||
// Also remove canonical 'email' pointing to current user
|
// Also remove canonical 'email' pointing to current user
|
||||||
await prefs.remove('email');
|
await prefs.remove('email');
|
||||||
// Do not delete display_name_<email> entries — they are per-account and should remain on device.
|
// Do not delete display_name_<email> entries — they are per-account and should remain on device.
|
||||||
|
PostHogService.instance.capture('user_logged_out');
|
||||||
|
PostHogService.instance.reset();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) debugPrint('AuthService.logout warning: $e');
|
if (kDebugMode) debugPrint('AuthService.logout warning: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
87
lib/features/booking/models/booking_models.dart
Normal file
87
lib/features/booking/models/booking_models.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<CartItemModel> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
147
lib/features/booking/providers/checkout_provider.dart
Normal file
147
lib/features/booking/providers/checkout_provider.dart
Normal file
@@ -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<TicketMetaModel> availableTickets = [];
|
||||||
|
List<CartItemModel> cart = [];
|
||||||
|
|
||||||
|
// Shipping
|
||||||
|
ShippingDetails? shippingDetails;
|
||||||
|
|
||||||
|
// Coupon
|
||||||
|
String? couponCode;
|
||||||
|
|
||||||
|
// Status
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
String? paymentId;
|
||||||
|
|
||||||
|
/// Initialize checkout for an event.
|
||||||
|
Future<void> 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<Map<String, dynamic>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/features/booking/services/booking_service.dart
Normal file
53
lib/features/booking/services/booking_service.dart
Normal file
@@ -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<List<TicketMetaModel>> 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<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add item to cart.
|
||||||
|
Future<Map<String, dynamic>> 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<Map<String, dynamic>> processCheckout({
|
||||||
|
required int eventId,
|
||||||
|
required List<Map<String, dynamic>> tickets,
|
||||||
|
required Map<String, dynamic> shippingDetails,
|
||||||
|
String? couponCode,
|
||||||
|
}) async {
|
||||||
|
return await _api.post(
|
||||||
|
ApiEndpoints.checkout,
|
||||||
|
body: {
|
||||||
|
'event_id': eventId,
|
||||||
|
'tickets': tickets,
|
||||||
|
'shipping': shippingDetails,
|
||||||
|
if (couponCode != null) 'coupon_code': couponCode,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/features/booking/services/payment_service.dart
Normal file
67
lib/features/booking/services/payment_service.dart
Normal file
@@ -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 = <String, dynamic>{
|
||||||
|
'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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,41 +94,162 @@ class UserGamificationProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LeaderboardEntry
|
// LeaderboardEntry — maps from Node.js API response fields
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
class LeaderboardEntry {
|
class LeaderboardEntry {
|
||||||
final int rank;
|
final int rank;
|
||||||
final String username;
|
final String username;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final int lifetimeEp;
|
final int lifetimeEp;
|
||||||
|
final int monthlyPoints;
|
||||||
final ContributorTier tier;
|
final ContributorTier tier;
|
||||||
final int eventsCount;
|
final int eventsCount;
|
||||||
final bool isCurrentUser;
|
final bool isCurrentUser;
|
||||||
|
final String? district;
|
||||||
|
|
||||||
const LeaderboardEntry({
|
const LeaderboardEntry({
|
||||||
required this.rank,
|
required this.rank,
|
||||||
required this.username,
|
required this.username,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
required this.lifetimeEp,
|
required this.lifetimeEp,
|
||||||
|
this.monthlyPoints = 0,
|
||||||
required this.tier,
|
required this.tier,
|
||||||
required this.eventsCount,
|
required this.eventsCount,
|
||||||
this.isCurrentUser = false,
|
this.isCurrentUser = false,
|
||||||
|
this.district,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) {
|
factory LeaderboardEntry.fromJson(Map<String, dynamic> 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(
|
return LeaderboardEntry(
|
||||||
rank: (json['rank'] as int?) ?? 0,
|
rank: (json['rank'] as num?)?.toInt() ?? 0,
|
||||||
username: json['username'] as String? ?? '',
|
username: json['name'] as String? ?? json['username'] as String? ?? '',
|
||||||
avatarUrl: json['avatar_url'] as String?,
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
lifetimeEp: ep,
|
lifetimeEp: ep,
|
||||||
tier: tierFromEp(ep),
|
monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0,
|
||||||
eventsCount: (json['events_count'] as int?) ?? 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,
|
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<String, dynamic> 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<LeaderboardEntry> 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<String> 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<String, dynamic> 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<SubmissionModel> submissions;
|
||||||
|
|
||||||
|
const DashboardResponse({
|
||||||
|
required this.profile,
|
||||||
|
this.submissions = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ShopItem — mirrors `RedeemShopItem` table
|
// ShopItem — mirrors `RedeemShopItem` table
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class GamificationProvider extends ChangeNotifier {
|
|||||||
List<LeaderboardEntry> leaderboard = [];
|
List<LeaderboardEntry> leaderboard = [];
|
||||||
List<ShopItem> shopItems = [];
|
List<ShopItem> shopItems = [];
|
||||||
List<AchievementBadge> achievements = [];
|
List<AchievementBadge> achievements = [];
|
||||||
|
List<SubmissionModel> submissions = [];
|
||||||
|
CurrentUserStats? currentUserStats;
|
||||||
|
int totalParticipants = 0;
|
||||||
|
|
||||||
// Leaderboard filters — matches web version
|
// Leaderboard filters — matches web version
|
||||||
String leaderboardDistrict = 'Overall Kerala';
|
String leaderboardDistrict = 'Overall Kerala';
|
||||||
@@ -31,14 +34,21 @@ class GamificationProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
_service.getProfile(),
|
_service.getDashboard(),
|
||||||
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
|
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
|
||||||
_service.getShopItems(),
|
_service.getShopItems(),
|
||||||
_service.getAchievements(),
|
_service.getAchievements(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
profile = results[0] as UserGamificationProfile;
|
final dashboard = results[0] as DashboardResponse;
|
||||||
leaderboard = results[1] as List<LeaderboardEntry>;
|
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<ShopItem>;
|
shopItems = results[2] as List<ShopItem>;
|
||||||
achievements = results[3] as List<AchievementBadge>;
|
achievements = results[3] as List<AchievementBadge>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -57,7 +67,10 @@ class GamificationProvider extends ChangeNotifier {
|
|||||||
leaderboardDistrict = district;
|
leaderboardDistrict = district;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
error = userFriendlyError(e);
|
error = userFriendlyError(e);
|
||||||
}
|
}
|
||||||
@@ -72,7 +85,10 @@ class GamificationProvider extends ChangeNotifier {
|
|||||||
leaderboardTimePeriod = period;
|
leaderboardTimePeriod = period;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
error = userFriendlyError(e);
|
error = userFriendlyError(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +1,139 @@
|
|||||||
// lib/features/gamification/services/gamification_service.dart
|
// lib/features/gamification/services/gamification_service.dart
|
||||||
//
|
//
|
||||||
// Stub service using the real API contract from TechDocs v2.
|
// Real API service for the Contributor / Gamification module.
|
||||||
// All methods currently return mock data.
|
// Calls the Node.js gamification server at app.eventifyplus.com.
|
||||||
// TODO: replace each mock block with a real ApiClient call once
|
|
||||||
// the backend endpoints are live on uat.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';
|
import '../models/gamification_models.dart';
|
||||||
|
|
||||||
class GamificationService {
|
class GamificationService {
|
||||||
|
final ApiClient _api = ApiClient();
|
||||||
|
|
||||||
|
/// Helper: get current user's email for API calls.
|
||||||
|
Future<String> _getUserEmail() async {
|
||||||
|
final email = await TokenStorage.getUsername();
|
||||||
|
return email ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// User Gamification Profile
|
// Dashboard (profile + submissions)
|
||||||
// TODO: replace with ApiClient.get(ApiEndpoints.gamificationProfile)
|
// GET /v1/gamification/dashboard?user_id={email}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<UserGamificationProfile> getProfile() async {
|
Future<DashboardResponse> getDashboard() async {
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
final email = await _getUserEmail();
|
||||||
return const UserGamificationProfile(
|
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$email';
|
||||||
userId: 'mock-user-001',
|
final res = await _api.post(url, requiresAuth: false);
|
||||||
lifetimeEp: 320,
|
|
||||||
currentEp: 70,
|
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||||
currentRp: 45,
|
final rawSubs = res['submissions'] as List? ?? [];
|
||||||
tier: ContributorTier.SILVER,
|
|
||||||
|
final submissions = rawSubs
|
||||||
|
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
profile: UserGamificationProfile.fromJson(profileJson),
|
||||||
|
submissions: submissions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience — returns just the profile (backward-compatible with provider).
|
||||||
|
Future<UserGamificationProfile> getProfile() async {
|
||||||
|
final dashboard = await getDashboard();
|
||||||
|
return dashboard.profile;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Leaderboard
|
// Leaderboard
|
||||||
// district: 'Overall Kerala' | 'Thiruvananthapuram' | 'Kollam' | ...
|
// GET /v1/gamification/leaderboard?period=all|month&district=X&user_id=Y&limit=50
|
||||||
// timePeriod: 'all_time' | 'this_month'
|
|
||||||
// TODO: replace with ApiClient.get(ApiEndpoints.leaderboard, params: {'district': district, 'period': timePeriod})
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<List<LeaderboardEntry>> getLeaderboard({
|
Future<LeaderboardResponse> getLeaderboard({
|
||||||
required String district,
|
required String district,
|
||||||
required String timePeriod,
|
required String timePeriod,
|
||||||
}) async {
|
}) async {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
final email = await _getUserEmail();
|
||||||
|
|
||||||
// Realistic mock names per district
|
// Map Flutter filter values to API params
|
||||||
final names = [
|
final period = timePeriod == 'this_month' ? 'month' : 'all';
|
||||||
'Annette Black', 'Jerome Bell', 'Theresa Webb', 'Courtney Henry',
|
|
||||||
'Cameron Williamson', 'Dianne Russell', 'Wade Warren', 'Albert Flores',
|
|
||||||
'Kristin Watson', 'Guy Hawkins',
|
|
||||||
];
|
|
||||||
|
|
||||||
final rng = Random(district.hashCode ^ timePeriod.hashCode);
|
final params = <String, String>{
|
||||||
final baseEp = timePeriod == 'this_month' ? 800 : 4500;
|
'period': period,
|
||||||
|
'user_id': email,
|
||||||
|
'limit': '50',
|
||||||
|
};
|
||||||
|
if (district != 'Overall Kerala') {
|
||||||
|
params['district'] = district;
|
||||||
|
}
|
||||||
|
|
||||||
final entries = List.generate(10, (i) {
|
final query = Uri(queryParameters: params).query;
|
||||||
final ep = baseEp - (i * (timePeriod == 'this_month' ? 55 : 280)) + rng.nextInt(30);
|
final url = '${ApiEndpoints.leaderboard}?$query';
|
||||||
return LeaderboardEntry(
|
final res = await _api.post(url, requiresAuth: false);
|
||||||
rank: i + 1,
|
|
||||||
username: names[i],
|
final rawList = res['leaderboard'] as List? ?? [];
|
||||||
lifetimeEp: ep,
|
final entries = rawList
|
||||||
tier: tierFromEp(ep),
|
.map((e) => LeaderboardEntry.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
eventsCount: 149 - i * 12,
|
.toList();
|
||||||
isCurrentUser: i == 7, // mock: current user is rank 8
|
|
||||||
|
CurrentUserStats? currentUser;
|
||||||
|
if (res['currentUser'] != null && res['currentUser'] is Map) {
|
||||||
|
currentUser = CurrentUserStats.fromJson(
|
||||||
|
Map<String, dynamic>.from(res['currentUser'] as Map),
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
return entries;
|
return LeaderboardResponse(
|
||||||
|
entries: entries,
|
||||||
|
currentUser: currentUser,
|
||||||
|
totalParticipants: (res['totalParticipants'] as num?)?.toInt() ?? entries.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Redeem Shop Items
|
// Shop Items
|
||||||
// TODO: replace with ApiClient.get(ApiEndpoints.shopItems)
|
// GET /v1/shop/items
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<List<ShopItem>> getShopItems() async {
|
Future<List<ShopItem>> getShopItems() async {
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
final res = await _api.post(ApiEndpoints.shopItems, requiresAuth: false);
|
||||||
return const [
|
final rawItems = res['items'] as List? ?? [];
|
||||||
ShopItem(
|
return rawItems
|
||||||
id: 'item-001',
|
.map((e) => ShopItem.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
name: 'Amazon ₹500 Voucher',
|
.toList();
|
||||||
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
|
// Redeem Item
|
||||||
// TODO: replace with ApiClient.post(ApiEndpoints.shopRedeem, body: {'item_id': itemId})
|
// POST /v1/shop/redeem body: { user_id, item_id }
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<RedemptionRecord> redeemItem(String itemId) async {
|
Future<RedemptionRecord> redeemItem(String itemId) async {
|
||||||
await Future.delayed(const Duration(milliseconds: 600));
|
final email = await _getUserEmail();
|
||||||
// Generate a fake voucher code
|
final res = await _api.post(
|
||||||
final code = 'EVT-${itemId.toUpperCase().replaceAll('-', '').substring(0, 4)}-${Random().nextInt(9000) + 1000}';
|
ApiEndpoints.shopRedeem,
|
||||||
return RedemptionRecord(
|
body: {'user_id': email, 'item_id': itemId},
|
||||||
id: 'redemption-${DateTime.now().millisecondsSinceEpoch}',
|
requiresAuth: false,
|
||||||
itemId: itemId,
|
|
||||||
rpSpent: 0, // provider will look up cost
|
|
||||||
voucherCode: code,
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
);
|
);
|
||||||
|
final voucher = res['voucher'] as Map<String, dynamic>? ?? res;
|
||||||
|
return RedemptionRecord.fromJson(Map<String, dynamic>.from(voucher));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Submit Contribution
|
// Submit Contribution
|
||||||
// TODO: replace with ApiClient.post(ApiEndpoints.contributeSubmit, body: data)
|
// POST /v1/gamification/submit-event body: event data
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<void> submitContribution(Map<String, dynamic> data) async {
|
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||||
await Future.delayed(const Duration(milliseconds: 800));
|
final email = await _getUserEmail();
|
||||||
// Mock always succeeds
|
final body = <String, dynamic>{'user_id': email, ...data};
|
||||||
|
await _api.post(
|
||||||
|
ApiEndpoints.contributeSubmit,
|
||||||
|
body: body,
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Achievements
|
// Achievements
|
||||||
|
// TODO: wire to achievements API when available on Node.js server
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<List<AchievementBadge>> getAchievements() async {
|
Future<List<AchievementBadge>> getAchievements() async {
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
|||||||
33
lib/features/notifications/models/notification_model.dart
Normal file
33
lib/features/notifications/models/notification_model.dart
Normal file
@@ -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<String, dynamic> 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NotificationModel> notifications = [];
|
||||||
|
int unreadCount = 0;
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
/// Load full notification list.
|
||||||
|
Future<void> 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<void> refreshUnreadCount() async {
|
||||||
|
try {
|
||||||
|
unreadCount = await _service.getUnreadCount();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {
|
||||||
|
// Silently fail — badge just won't update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark single notification as read.
|
||||||
|
Future<void> 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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<NotificationModel>> 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<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a single notification as read, or all if [notificationId] is null.
|
||||||
|
Future<void> markAsRead({int? notificationId}) async {
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
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<int> getUnreadCount() async {
|
||||||
|
final res = await _api.post(ApiEndpoints.notificationCount);
|
||||||
|
return (res['unread_count'] as num?)?.toInt() ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/features/notifications/widgets/notification_bell.dart
Normal file
61
lib/features/notifications/widgets/notification_bell.dart
Normal file
@@ -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<NotificationProvider>(
|
||||||
|
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<NotificationProvider>().loadNotifications();
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ChangeNotifierProvider.value(
|
||||||
|
value: context.read<NotificationProvider>(),
|
||||||
|
child: const NotificationPanel(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
lib/features/notifications/widgets/notification_panel.dart
Normal file
101
lib/features/notifications/widgets/notification_panel.dart
Normal file
@@ -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<NotificationProvider>(
|
||||||
|
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<NotificationProvider>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/features/notifications/widgets/notification_tile.dart
Normal file
94
lib/features/notifications/widgets/notification_tile.dart
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,17 +2,24 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/home_desktop_screen.dart';
|
import 'screens/home_desktop_screen.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'screens/desktop_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/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 {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await ThemeManager.init(); // load saved theme preference
|
await ThemeManager.init(); // load saved theme preference
|
||||||
|
await PostHogService.instance.init();
|
||||||
|
|
||||||
// Increase image cache for smoother scrolling and faster re-renders
|
// Increase image cache for smoother scrolling and faster re-renders
|
||||||
PaintingBinding.instance.imageCache.maximumSize = 500;
|
PaintingBinding.instance.imageCache.maximumSize = 500;
|
||||||
@@ -90,7 +97,14 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<ThemeMode>(
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => GamificationProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => CheckoutProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => NotificationProvider()),
|
||||||
|
],
|
||||||
|
child: ValueListenableBuilder<ThemeMode>(
|
||||||
valueListenable: ThemeManager.themeMode,
|
valueListenable: ThemeManager.themeMode,
|
||||||
builder: (context, mode, _) {
|
builder: (context, mode, _) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
@@ -102,6 +116,7 @@ class MyApp extends StatelessWidget {
|
|||||||
home: const StartupScreen(),
|
home: const StartupScreen(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
// lib/screens/booking_screen.dart
|
// lib/screens/booking_screen.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'checkout_screen.dart';
|
||||||
|
|
||||||
class BookingScreen extends StatefulWidget {
|
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 VoidCallback? onBook;
|
||||||
final String image;
|
final String image;
|
||||||
|
final int? eventId;
|
||||||
|
final String? eventName;
|
||||||
|
|
||||||
const BookingScreen({
|
const BookingScreen({
|
||||||
Key? key,
|
Key? key,
|
||||||
this.onBook,
|
this.onBook,
|
||||||
this.image = 'assets/images/event1.jpg',
|
this.image = 'assets/images/event1.jpg',
|
||||||
|
this.eventId,
|
||||||
|
this.eventName,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,11 +43,22 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
|
|||||||
bool _booked = false;
|
bool _booked = false;
|
||||||
|
|
||||||
void _performLocalBooking() {
|
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) {
|
if (!_booked) {
|
||||||
setState(() => _booked = true);
|
setState(() => _booked = true);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Tickets booked (demo)')),
|
const SnackBar(content: Text('Tickets booked (demo)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
392
lib/screens/checkout_screen.dart
Normal file
392
lib/screens/checkout_screen.dart
Normal file
@@ -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<CheckoutScreen> createState() => _CheckoutScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckoutScreenState extends State<CheckoutScreen> {
|
||||||
|
late final PaymentService _paymentService;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<CheckoutProvider>().initForEvent(widget.eventId, widget.eventName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<CheckoutProvider>();
|
||||||
|
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<CheckoutProvider>(
|
||||||
|
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<void> _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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import 'package:geocoding/geocoding.dart';
|
|||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../features/gamification/providers/gamification_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 {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({Key? key}) : super(key: key);
|
const HomeScreen({Key? key}) : super(key: key);
|
||||||
@@ -126,6 +128,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh notification badge count (fire-and-forget)
|
||||||
|
if (mounted) {
|
||||||
|
context.read<NotificationProvider>().refreshUnreadCount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reverseGeocodeAndSave(double lat, double lng, SharedPreferences prefs) async {
|
Future<void> _reverseGeocodeAndSave(double lat, double lng, SharedPreferences prefs) async {
|
||||||
@@ -438,12 +445,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
children: [
|
children: [
|
||||||
RepaintBoundary(child: _buildHomeContent()), // index 0
|
RepaintBoundary(child: _buildHomeContent()), // index 0
|
||||||
const RepaintBoundary(child: CalendarScreen()), // index 1
|
const RepaintBoundary(child: CalendarScreen()), // index 1
|
||||||
RepaintBoundary(
|
const RepaintBoundary(child: ContributeScreen()), // index 2 (full page, scrollable)
|
||||||
child: ChangeNotifierProvider(
|
|
||||||
create: (_) => GamificationProvider(),
|
|
||||||
child: const ContributeScreen(),
|
|
||||||
),
|
|
||||||
), // index 2 (full page, scrollable)
|
|
||||||
const RepaintBoundary(child: ProfileScreen()), // index 3
|
const RepaintBoundary(child: ProfileScreen()), // index 3
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1221,6 +1223,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const NotificationBell(),
|
||||||
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _openEventSearch,
|
onTap: _openEventSearch,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1236,6 +1243,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import '../core/utils/error_utils.dart';
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../features/auth/services/auth_service.dart';
|
import '../features/auth/services/auth_service.dart';
|
||||||
|
import '../features/auth/providers/auth_provider.dart';
|
||||||
import '../core/auth/auth_guard.dart';
|
import '../core/auth/auth_guard.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
import 'responsive_layout.dart';
|
||||||
|
import 'home_desktop_screen.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({Key? key}) : super(key: key);
|
const LoginScreen({Key? key}) : super(key: key);
|
||||||
@@ -124,6 +128,27 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _performGoogleLogin() async {
|
||||||
|
try {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await Provider.of<AuthProvider>(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
|
/// Glassmorphism pill-shaped input decoration
|
||||||
InputDecoration _glassInputDecoration({
|
InputDecoration _glassInputDecoration({
|
||||||
required String hint,
|
required String hint,
|
||||||
@@ -474,7 +499,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
color: Color(0xFF4285F4),
|
color: Color(0xFF4285F4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: _showComingSoon,
|
onTap: _performGoogleLogin,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_socialButton(
|
_socialButton(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Foundation
|
|||||||
|
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
|
import google_sign_in_ios
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -17,6 +18,7 @@ import video_player_avfoundation
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
66
pubspec.lock
66
pubspec.lock
@@ -137,6 +137,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
|
eventify:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: eventify
|
||||||
|
sha256: b829429f08586cc2001c628e7499e3e3c2493a1d895fd73b00ecb23351aa5a66
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -336,6 +344,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.3"
|
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:
|
google_maps:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -384,6 +400,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.14+3"
|
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:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -393,7 +449,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.6"
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||||
@@ -672,6 +728,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
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:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ dependencies:
|
|||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
video_player: ^2.8.1
|
video_player: ^2.8.1
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
|
razorpay_flutter: ^1.3.7
|
||||||
|
google_sign_in: ^6.2.2
|
||||||
|
http: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user