Compare commits
28 Commits
main
...
feature/pa
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf0016ccc | |||
| 3008a6ab79 | |||
| e674d18542 | |||
| 3ad3515d8d | |||
| 32d74d23dd | |||
| 79c34a62d7 | |||
| b0b68d688e | |||
| 4aaf8a6bf1 | |||
| 21adc5e556 | |||
| 7e427237c3 | |||
| 12fbef19c5 | |||
| c556df1f80 | |||
| 70fcc2e662 | |||
| 2d95e92edb | |||
| 8415b6549d | |||
| 6c0f325a44 | |||
| ff9c712a7e | |||
| 7e64e8c091 | |||
| c5ebf17dbc | |||
| 9dfdac0323 | |||
| ea82d65a81 | |||
| 8f15a04c88 | |||
| 4a1bd85435 | |||
| f70138238b | |||
| d071fa6c39 | |||
| 27de1a5306 | |||
| dad3d98896 | |||
| 1d8ec14a87 |
284
lib/controller/payment/payment_controller.dart
Normal file
284
lib/controller/payment/payment_controller.dart
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:razorpay_flutter/razorpay_flutter.dart';
|
||||||
|
import 'package:marco/helpers/services/payment_service.dart';
|
||||||
|
import 'package:marco/helpers/services/tenant_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
class PaymentController with ChangeNotifier {
|
||||||
|
Razorpay? _razorpay;
|
||||||
|
final PaymentService _paymentService = PaymentService();
|
||||||
|
final TenantService _tenantService = TenantService();
|
||||||
|
|
||||||
|
bool isProcessing = false;
|
||||||
|
|
||||||
|
// Pending values to use after payment verification
|
||||||
|
String? _pendingTenantEnquireId;
|
||||||
|
String? _pendingPlanId;
|
||||||
|
|
||||||
|
/// ==============================
|
||||||
|
/// START PAYMENT (Safe init)
|
||||||
|
/// ==============================
|
||||||
|
Future<void> startPayment({
|
||||||
|
required double amount,
|
||||||
|
required String description,
|
||||||
|
required BuildContext context,
|
||||||
|
String? tenantEnquireId,
|
||||||
|
String? planId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
isProcessing = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Save pending ids for post-payment subscription call
|
||||||
|
_pendingTenantEnquireId = tenantEnquireId;
|
||||||
|
_pendingPlanId = planId;
|
||||||
|
|
||||||
|
// Step 1: Create payment order
|
||||||
|
final result = await _paymentService.createOrder(amount);
|
||||||
|
logSafe("🧩 Raw response in PaymentController: $result");
|
||||||
|
|
||||||
|
// Step 2: Validate result before accessing keys
|
||||||
|
if (result == null) {
|
||||||
|
_showError("Failed to create order. Server returned null response.");
|
||||||
|
isProcessing = false;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Handle both wrapped and unwrapped formats
|
||||||
|
final data = result['data'] ?? result;
|
||||||
|
final orderId = data?['orderId'];
|
||||||
|
final key = data?['key'];
|
||||||
|
|
||||||
|
if (orderId == null || key == null) {
|
||||||
|
_showError("Invalid response from server. Missing orderId or key.");
|
||||||
|
logSafe("💥 Invalid response structure: $result");
|
||||||
|
isProcessing = false;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Initialize Razorpay if needed
|
||||||
|
_razorpay ??= Razorpay();
|
||||||
|
_razorpay!.on(Razorpay.EVENT_PAYMENT_SUCCESS, _handlePaymentSuccess);
|
||||||
|
_razorpay!.on(Razorpay.EVENT_PAYMENT_ERROR, _handlePaymentError);
|
||||||
|
_razorpay!.on(Razorpay.EVENT_EXTERNAL_WALLET, _handleExternalWallet);
|
||||||
|
|
||||||
|
// Step 5: Open Razorpay checkout
|
||||||
|
final options = {
|
||||||
|
'key': key,
|
||||||
|
'amount': (amount * 100).toInt(), // Razorpay expects amount in paise
|
||||||
|
'name': 'Marco',
|
||||||
|
'description': description,
|
||||||
|
'order_id': orderId,
|
||||||
|
'prefill': {'contact': '9999999999', 'email': 'test@marco.com'},
|
||||||
|
};
|
||||||
|
|
||||||
|
logSafe("🟠 Opening Razorpay checkout with options: $options");
|
||||||
|
_razorpay!.open(options);
|
||||||
|
} catch (e, s) {
|
||||||
|
_showError("Payment initialization failed. Please try again.");
|
||||||
|
logSafe("💥 Exception in startPayment: $e\n$s");
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==============================
|
||||||
|
/// EVENT HANDLERS
|
||||||
|
/// ==============================
|
||||||
|
void _handlePaymentSuccess(PaymentSuccessResponse response) async {
|
||||||
|
logSafe("✅ Payment Success: ${response.paymentId}");
|
||||||
|
isProcessing = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
Map<String, dynamic>? verificationResult;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logSafe("🟢 Verifying payment via backend...");
|
||||||
|
verificationResult = await _paymentService
|
||||||
|
.verifyPayment(
|
||||||
|
paymentId: response.paymentId!,
|
||||||
|
orderId: response.orderId!,
|
||||||
|
signature: response.signature!,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
logSafe("🧩 Verification result: $verificationResult");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("⏱️ Verification timeout/error: $e", level: LogLevel.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Handle backend verification response properly
|
||||||
|
if (verificationResult != null) {
|
||||||
|
final isVerified = verificationResult['verified'] == true;
|
||||||
|
final msg = verificationResult['message'] ?? '';
|
||||||
|
|
||||||
|
if (isVerified) {
|
||||||
|
// If we have pending tenant and plan IDs, call subscription API
|
||||||
|
await _maybeSubscribeTenantAfterPayment(
|
||||||
|
verificationResult: verificationResult,
|
||||||
|
razorpayResponse: response,
|
||||||
|
);
|
||||||
|
|
||||||
|
_showDialog(
|
||||||
|
title: "Payment Successful 🎉",
|
||||||
|
message: msg.isNotEmpty ? msg : "Your payment was verified successfully.",
|
||||||
|
success: true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_showDialog(
|
||||||
|
title: "Verification Failed ❌",
|
||||||
|
message: msg.isNotEmpty ? msg : "Payment completed but verification failed.",
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_showDialog(
|
||||||
|
title: "Verification Failed ❌",
|
||||||
|
message: "Payment completed but backend verification returned null.",
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _maybeSubscribeTenantAfterPayment({
|
||||||
|
required Map<String, dynamic> verificationResult,
|
||||||
|
required PaymentSuccessResponse razorpayResponse,
|
||||||
|
}) async {
|
||||||
|
if (_pendingTenantEnquireId == null || _pendingPlanId == null) {
|
||||||
|
logSafe("ℹ️ No pending tenant/plan id to subscribe.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine paymentDetailId — prefer backend value if provided
|
||||||
|
final paymentDetailId = verificationResult['paymentDetailId'] ?? verificationResult['paymentId'] ?? razorpayResponse.paymentId;
|
||||||
|
|
||||||
|
final subscribePayload = {
|
||||||
|
"tenantEnquireId": _pendingTenantEnquireId,
|
||||||
|
"paymentDetailId": paymentDetailId,
|
||||||
|
"planId": _pendingPlanId,
|
||||||
|
};
|
||||||
|
|
||||||
|
logSafe("🟢 Subscribing tenant automatically: $subscribePayload");
|
||||||
|
|
||||||
|
final subResp = await _tenantService.subscribeTenant(subscribePayload);
|
||||||
|
|
||||||
|
// Clear pending values immediately to avoid double-invoke
|
||||||
|
_pendingTenantEnquireId = null;
|
||||||
|
_pendingPlanId = null;
|
||||||
|
|
||||||
|
if (subResp == null) {
|
||||||
|
logSafe("❌ subscribeTenant returned null");
|
||||||
|
_showDialog(
|
||||||
|
title: "Subscription Failed",
|
||||||
|
message: "Failed to call subscription API. Please contact support.",
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = subResp['data'] ?? subResp;
|
||||||
|
// backend success flag might be in different places; check robustly
|
||||||
|
final bool success = (data is Map && (data['success'] == true || subResp['success'] == true)) || (subResp['statusCode'] == 200);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
_showDialog(
|
||||||
|
title: "Subscription Active ✅",
|
||||||
|
message: data['message'] ?? "Tenant subscribed successfully.",
|
||||||
|
success: true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_showDialog(
|
||||||
|
title: "Subscription Failed",
|
||||||
|
message: data['message'] ?? "Subscription API did not confirm success.",
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePaymentError(PaymentFailureResponse response) {
|
||||||
|
logSafe("❌ Payment Failed: ${response.message}");
|
||||||
|
isProcessing = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_showDialog(
|
||||||
|
title: "Payment Failed ❌",
|
||||||
|
message: "Reason: ${response.message ?? 'Unknown error'}",
|
||||||
|
success: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
_cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleExternalWallet(ExternalWalletResponse response) {
|
||||||
|
logSafe("ℹ️ External Wallet Used: ${response.walletName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ==============================
|
||||||
|
/// CLEANUP / DISPOSE
|
||||||
|
/// ==============================
|
||||||
|
void _cleanup() {
|
||||||
|
try {
|
||||||
|
_razorpay?.clear();
|
||||||
|
} catch (_) {}
|
||||||
|
_razorpay = null;
|
||||||
|
logSafe("🧹 Razorpay instance cleaned up.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cleanup();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void disposeController() => _cleanup();
|
||||||
|
|
||||||
|
/// ==============================
|
||||||
|
/// HELPER UI FUNCTIONS
|
||||||
|
/// ==============================
|
||||||
|
void _showDialog({
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
required bool success,
|
||||||
|
}) {
|
||||||
|
if (Get.isDialogOpen == true) Get.back(); // Close any existing dialogs
|
||||||
|
|
||||||
|
Get.defaultDialog(
|
||||||
|
title: title,
|
||||||
|
middleText: message,
|
||||||
|
confirm: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Get.back(); // close dialog
|
||||||
|
Get.snackbar(
|
||||||
|
success ? "Payment Successful 🎉" : "Payment Failed ❌",
|
||||||
|
success
|
||||||
|
? "Payment verified successfully!"
|
||||||
|
: "Payment failed or could not be verified.",
|
||||||
|
backgroundColor: success ? Colors.green : Colors.red,
|
||||||
|
colorText: Colors.white,
|
||||||
|
snackPosition: SnackPosition.BOTTOM,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text("OK"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
|
if (Get.isDialogOpen == true) Get.back(); // Close any open dialog
|
||||||
|
Get.snackbar(
|
||||||
|
"Payment Error",
|
||||||
|
message,
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
colorText: Colors.white,
|
||||||
|
snackPosition: SnackPosition.BOTTOM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/controller/subscriptions/subscription_controller.dart
Normal file
38
lib/controller/subscriptions/subscription_controller.dart
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
|
||||||
|
class SubscriptionController extends GetxController {
|
||||||
|
var plans = <Map<String, dynamic>>[].obs;
|
||||||
|
var isLoading = true.obs;
|
||||||
|
|
||||||
|
// Frequency tabs
|
||||||
|
final frequencies = ['monthly', 'quarterly', 'half-yearly', 'yearly'];
|
||||||
|
var selectedFrequency = 'monthly'.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
fetchPlans(selectedFrequency.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPlans(String frequency) async {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
selectedFrequency.value = frequency;
|
||||||
|
|
||||||
|
final response = await ApiService.getSubscriptionPlans(frequency);
|
||||||
|
if (response != null &&
|
||||||
|
response['success'] == true &&
|
||||||
|
response['data'] != null) {
|
||||||
|
plans.value = List<Map<String, dynamic>>.from(response['data']);
|
||||||
|
} else {
|
||||||
|
plans.clear();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error fetching plans: $e");
|
||||||
|
plans.clear();
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
class ApiEndpoints {
|
class ApiEndpoints {
|
||||||
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||||
static const String baseUrl = "https://api.marcoaiot.com/api";
|
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||||
|
|
||||||
// Dashboard Module API Endpoints
|
// Dashboard Module API Endpoints
|
||||||
@ -100,4 +100,15 @@ class ApiEndpoints {
|
|||||||
static const getAllOrganizations = "/organization/list";
|
static const getAllOrganizations = "/organization/list";
|
||||||
|
|
||||||
static const String getAssignedServices = "/Project/get/assigned/services";
|
static const String getAssignedServices = "/Project/get/assigned/services";
|
||||||
|
|
||||||
|
// Payment Module API Endpoints
|
||||||
|
static const String createOrder = "/payment/create-order";
|
||||||
|
static const String verifyPayment = "/payment/verify-payment";
|
||||||
|
|
||||||
|
// Tenant endpoints
|
||||||
|
static const String createTenantSelf = '/Tenant/self/create';
|
||||||
|
static const String tenantSubscribe = '/Tenant/self/subscription';
|
||||||
|
static const String tenantRenewSubscription = '/Tenant/renew/subscription';
|
||||||
|
static const String getIndustries = '/market/industries';
|
||||||
|
static const String getCurrentSubscription = '/subscription/current';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,10 +75,17 @@ class ApiService {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, String> _headers(String token) => {
|
static Map<String, String> _headers(String? token) {
|
||||||
'Content-Type': 'application/json',
|
final headers = {
|
||||||
'Authorization': 'Bearer $token',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 👇 Only add Authorization header if token is available
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
static void _log(String message) {
|
static void _log(String message) {
|
||||||
if (enableLogs) logSafe(message);
|
if (enableLogs) logSafe(message);
|
||||||
@ -86,16 +93,26 @@ class ApiService {
|
|||||||
|
|
||||||
static dynamic _parseResponse(http.Response response, {String label = ''}) {
|
static dynamic _parseResponse(http.Response response, {String label = ''}) {
|
||||||
_log("$label Response: ${response.body}");
|
_log("$label Response: ${response.body}");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(response.body);
|
final Map<String, dynamic> json = jsonDecode(response.body);
|
||||||
if (response.statusCode == 200 && json['success'] == true) {
|
|
||||||
return json['data'];
|
// ✅ Treat 200–299 range as success
|
||||||
|
final isHttpOk = response.statusCode >= 200 && response.statusCode < 300;
|
||||||
|
final isSuccess = json['success'] == true;
|
||||||
|
|
||||||
|
if (isHttpOk && isSuccess) {
|
||||||
|
return json['data']; // return full data block for use in payment
|
||||||
}
|
}
|
||||||
_log("API Error [$label]: ${json['message'] ?? 'Unknown error'}");
|
|
||||||
|
// ⚠️ Log any API-level error
|
||||||
|
_log(
|
||||||
|
"API Error [$label]: ${json['message'] ?? 'Unknown error'} (code ${response.statusCode})");
|
||||||
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log("Response parsing error [$label]: $e");
|
_log("Response parsing error [$label]: $e");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static dynamic _parseResponseForAllData(http.Response response,
|
static dynamic _parseResponseForAllData(http.Response response,
|
||||||
@ -123,32 +140,49 @@ class ApiService {
|
|||||||
String endpoint, {
|
String endpoint, {
|
||||||
Map<String, String>? queryParams,
|
Map<String, String>? queryParams,
|
||||||
bool hasRetried = false,
|
bool hasRetried = false,
|
||||||
|
bool requireAuth = true,
|
||||||
}) async {
|
}) async {
|
||||||
String? token = await _getToken();
|
// ✅ Allow public (no-login) API calls for subscription & payment
|
||||||
if (token == null) {
|
final isPublicEndpoint =
|
||||||
logSafe("Token is null. Forcing logout from GET request.",
|
endpoint.contains("/subscription") || endpoint.contains("/payment");
|
||||||
level: LogLevel.error);
|
|
||||||
await LocalStorage.logout();
|
String? token;
|
||||||
return null;
|
if (requireAuth && !isPublicEndpoint) {
|
||||||
|
token = await _getToken();
|
||||||
|
if (token == null) {
|
||||||
|
logSafe("⛔ Token is null. Forcing logout from GET request.",
|
||||||
|
level: LogLevel.error);
|
||||||
|
await LocalStorage.logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
|
||||||
.replace(queryParameters: queryParams);
|
.replace(queryParameters: queryParams);
|
||||||
|
|
||||||
logSafe("Initiating GET request", level: LogLevel.debug);
|
logSafe("🌐 Initiating GET request", level: LogLevel.debug);
|
||||||
logSafe("URL: $uri", level: LogLevel.debug);
|
logSafe("URL: $uri", level: LogLevel.debug);
|
||||||
logSafe("Query Parameters: ${queryParams ?? {}}", level: LogLevel.debug);
|
logSafe("Query Parameters: ${queryParams ?? {}}", level: LogLevel.debug);
|
||||||
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
|
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http
|
final response = await http
|
||||||
.get(uri, headers: _headers(token))
|
.get(
|
||||||
|
uri,
|
||||||
|
headers: (isPublicEndpoint || !requireAuth)
|
||||||
|
? _headers(null)
|
||||||
|
: _headers(token),
|
||||||
|
)
|
||||||
.timeout(extendedTimeout);
|
.timeout(extendedTimeout);
|
||||||
|
|
||||||
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
|
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
|
||||||
logSafe("Response Body: ${response.body}", level: LogLevel.debug);
|
logSafe("Response Body: ${response.body}", level: LogLevel.debug);
|
||||||
|
|
||||||
if (response.statusCode == 401 && !hasRetried) {
|
// 🔒 Retry only for private endpoints
|
||||||
|
if (response.statusCode == 401 &&
|
||||||
|
!hasRetried &&
|
||||||
|
requireAuth &&
|
||||||
|
!isPublicEndpoint) {
|
||||||
logSafe("Unauthorized (401). Attempting token refresh...",
|
logSafe("Unauthorized (401). Attempting token refresh...",
|
||||||
level: LogLevel.warning);
|
level: LogLevel.warning);
|
||||||
|
|
||||||
@ -159,6 +193,7 @@ class ApiService {
|
|||||||
endpoint,
|
endpoint,
|
||||||
queryParams: queryParams,
|
queryParams: queryParams,
|
||||||
hasRetried: true,
|
hasRetried: true,
|
||||||
|
requireAuth: requireAuth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,33 +211,68 @@ class ApiService {
|
|||||||
|
|
||||||
static Future<http.Response?> _postRequest(
|
static Future<http.Response?> _postRequest(
|
||||||
String endpoint,
|
String endpoint,
|
||||||
dynamic body, {
|
dynamic data, {
|
||||||
Duration customTimeout = extendedTimeout,
|
// <-- changed from Map<String, dynamic> to dynamic
|
||||||
|
Map<String, String>? queryParams,
|
||||||
bool hasRetried = false,
|
bool hasRetried = false,
|
||||||
|
bool requireAuth = true,
|
||||||
|
Duration? customTimeout,
|
||||||
}) async {
|
}) async {
|
||||||
String? token = await _getToken();
|
// ✅ Allow public (no-login) API calls for subscription & payment
|
||||||
if (token == null) return null;
|
final isPublicEndpoint =
|
||||||
|
endpoint.contains("/subscription") || endpoint.contains("/payment");
|
||||||
|
|
||||||
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
String? token = await _getToken();
|
||||||
logSafe(
|
|
||||||
"POST $uri\nHeaders: ${_headers(token)}\nBody: $body",
|
if (token == null && requireAuth && !isPublicEndpoint) {
|
||||||
);
|
logSafe("⛔ Token missing for private POST: $endpoint",
|
||||||
|
level: LogLevel.error);
|
||||||
|
await LocalStorage.logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
|
||||||
|
.replace(queryParameters: queryParams);
|
||||||
|
|
||||||
|
logSafe("🌐 POST $uri", level: LogLevel.debug);
|
||||||
|
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
|
||||||
|
logSafe("Body: $data", level: LogLevel.debug);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http
|
final response = await http
|
||||||
.post(uri, headers: _headers(token), body: jsonEncode(body))
|
.post(
|
||||||
.timeout(customTimeout);
|
uri,
|
||||||
|
headers: (isPublicEndpoint || !requireAuth)
|
||||||
|
? _headers(null)
|
||||||
|
: _headers(token),
|
||||||
|
body: jsonEncode(data), // handles both Map and List
|
||||||
|
)
|
||||||
|
.timeout(customTimeout ?? extendedTimeout);
|
||||||
|
|
||||||
if (response.statusCode == 401 && !hasRetried) {
|
logSafe("Response ${response.statusCode}: ${response.body}",
|
||||||
logSafe("Unauthorized POST. Attempting token refresh...");
|
level: LogLevel.debug);
|
||||||
|
|
||||||
|
// Retry token refresh for private routes only
|
||||||
|
if (response.statusCode == 401 &&
|
||||||
|
!hasRetried &&
|
||||||
|
requireAuth &&
|
||||||
|
!isPublicEndpoint) {
|
||||||
if (await AuthService.refreshToken()) {
|
if (await AuthService.refreshToken()) {
|
||||||
return await _postRequest(endpoint, body,
|
return await _postRequest(
|
||||||
customTimeout: customTimeout, hasRetried: true);
|
endpoint,
|
||||||
|
data,
|
||||||
|
queryParams: queryParams,
|
||||||
|
hasRetried: true,
|
||||||
|
requireAuth: requireAuth,
|
||||||
|
customTimeout: customTimeout,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
await LocalStorage.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("HTTP POST Exception: $e", level: LogLevel.error);
|
logSafe("❌ HTTP POST Exception: $e", level: LogLevel.error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,16 +283,20 @@ class ApiService {
|
|||||||
Map<String, String>? additionalHeaders,
|
Map<String, String>? additionalHeaders,
|
||||||
Duration customTimeout = extendedTimeout,
|
Duration customTimeout = extendedTimeout,
|
||||||
bool hasRetried = false,
|
bool hasRetried = false,
|
||||||
|
bool requireAuth = true, // ✅ added
|
||||||
}) async {
|
}) async {
|
||||||
String? token = await _getToken();
|
String? token;
|
||||||
if (token == null) return null;
|
|
||||||
|
if (requireAuth) {
|
||||||
|
token = await _getToken();
|
||||||
|
if (token == null) return null;
|
||||||
|
}
|
||||||
|
|
||||||
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
||||||
logSafe(
|
|
||||||
"PUT $uri\nHeaders: ${_headers(token)}\nBody: $body",
|
|
||||||
);
|
|
||||||
final headers = {
|
final headers = {
|
||||||
..._headers(token),
|
'Content-Type': 'application/json',
|
||||||
|
if (requireAuth && token != null) ..._headers(token),
|
||||||
if (additionalHeaders != null) ...additionalHeaders,
|
if (additionalHeaders != null) ...additionalHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -235,19 +309,23 @@ class ApiService {
|
|||||||
.put(uri, headers: headers, body: jsonEncode(body))
|
.put(uri, headers: headers, body: jsonEncode(body))
|
||||||
.timeout(customTimeout);
|
.timeout(customTimeout);
|
||||||
|
|
||||||
if (response.statusCode == 401 && !hasRetried) {
|
if (response.statusCode == 401 && requireAuth && !hasRetried) {
|
||||||
logSafe("Unauthorized PUT. Attempting token refresh...");
|
logSafe("⚠️ Unauthorized PUT. Attempting token refresh...");
|
||||||
if (await AuthService.refreshToken()) {
|
if (await AuthService.refreshToken()) {
|
||||||
return await _putRequest(endpoint, body,
|
return await _putRequest(
|
||||||
additionalHeaders: additionalHeaders,
|
endpoint,
|
||||||
customTimeout: customTimeout,
|
body,
|
||||||
hasRetried: true);
|
additionalHeaders: additionalHeaders,
|
||||||
|
customTimeout: customTimeout,
|
||||||
|
hasRetried: true,
|
||||||
|
requireAuth: requireAuth,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("HTTP PUT Exception: $e", level: LogLevel.error);
|
logSafe("❌ HTTP PUT Exception: $e", level: LogLevel.error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1824,7 +1902,7 @@ class ApiService {
|
|||||||
_log("Deleting directory contact at $uri");
|
_log("Deleting directory contact at $uri");
|
||||||
|
|
||||||
final response = await _deleteRequest(
|
final response = await _deleteRequest(
|
||||||
"$endpoint?active=false",
|
"$endpoint?active=false",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response != null && response.statusCode == 200) {
|
if (response != null && response.statusCode == 200) {
|
||||||
@ -1957,6 +2035,53 @@ class ApiService {
|
|||||||
? _parseResponseForAllData(res, label: 'Contact Bucket List')
|
? _parseResponseForAllData(res, label: 'Contact Bucket List')
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
|
/// ==============================
|
||||||
|
/// SUBSCRIPTION API
|
||||||
|
/// ==============================
|
||||||
|
static Future<Map<String, dynamic>?> getSubscriptionPlans(
|
||||||
|
String frequency) async {
|
||||||
|
try {
|
||||||
|
final endpoint = "/market/list/subscription-plan?frequency=$frequency";
|
||||||
|
logSafe("Fetching subscription plans for frequency: $frequency");
|
||||||
|
|
||||||
|
// 👇 Pass `requireAuth: false` to make this API public
|
||||||
|
final response = await _getRequest(endpoint, requireAuth: false);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Subscription plans request failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = response.body.trim();
|
||||||
|
if (body.isEmpty) {
|
||||||
|
logSafe("Subscription plans response body is empty",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonResponse = jsonDecode(body);
|
||||||
|
if (jsonResponse is Map<String, dynamic>) {
|
||||||
|
if (jsonResponse['success'] == true) {
|
||||||
|
logSafe("Subscription plans fetched successfully");
|
||||||
|
return jsonResponse; // Return full JSON, PaymentController will handle parsing
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to fetch subscription plans: ${jsonResponse['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logSafe("Unexpected subscription response format: $jsonResponse",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("❌ Exception during getSubscriptionPlans: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// === Attendance APIs ===
|
// === Attendance APIs ===
|
||||||
|
|
||||||
static Future<List<dynamic>?> getProjects() async =>
|
static Future<List<dynamic>?> getProjects() async =>
|
||||||
@ -2085,6 +2210,135 @@ class ApiService {
|
|||||||
return "${employeeId}_${dateStr}_$imageNumber.jpg";
|
return "${employeeId}_${dateStr}_$imageNumber.jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a payment order
|
||||||
|
static Future<Map<String, dynamic>?> createPaymentOrder(double amount) async {
|
||||||
|
const endpoint = ApiEndpoints.createOrder; // endpoint for order creation
|
||||||
|
try {
|
||||||
|
// ✅ Allow this API call without requiring login/token
|
||||||
|
final response = await _postRequest(
|
||||||
|
endpoint,
|
||||||
|
{'amount': amount},
|
||||||
|
requireAuth: false, // 👈 this is the key change
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) return null;
|
||||||
|
return _parseResponse(response, label: "Create Payment Order");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Exception during createPaymentOrder: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a payment
|
||||||
|
static Future<Map<String, dynamic>?> verifyPayment({
|
||||||
|
required String orderId,
|
||||||
|
required String paymentId,
|
||||||
|
required String signature,
|
||||||
|
}) async {
|
||||||
|
const endpoint = ApiEndpoints.verifyPayment;
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
'orderId': orderId,
|
||||||
|
'paymentId': paymentId,
|
||||||
|
'signature': signature,
|
||||||
|
},
|
||||||
|
requireAuth: false,
|
||||||
|
);
|
||||||
|
if (response == null) return null;
|
||||||
|
return _parseResponse(response, label: "Verify Payment");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("Exception during verifyPayment: $e", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create tenant (self)
|
||||||
|
static Future<Map<String, dynamic>?> createTenantSelf(
|
||||||
|
Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(
|
||||||
|
ApiEndpoints.createTenantSelf,
|
||||||
|
payload,
|
||||||
|
requireAuth: false, // likely public
|
||||||
|
);
|
||||||
|
if (response == null) return null;
|
||||||
|
return _parseResponse(response, label: "Create Tenant Self");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Exception in createTenantSelf: $e", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe tenant (after successful payment)
|
||||||
|
static Future<Map<String, dynamic>?> subscribeTenant(
|
||||||
|
Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(
|
||||||
|
ApiEndpoints.tenantSubscribe,
|
||||||
|
payload,
|
||||||
|
requireAuth:
|
||||||
|
true, // likely needs auth if user logged in; set according to backend
|
||||||
|
);
|
||||||
|
if (response == null) return null;
|
||||||
|
return _parseResponse(response, label: "Tenant Subscribe");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Exception in subscribeTenant: $e", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renew tenant subscription (PUT)
|
||||||
|
static Future<Map<String, dynamic>?> renewTenantSubscription(
|
||||||
|
Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
final response = await _putRequest(
|
||||||
|
ApiEndpoints.tenantRenewSubscription,
|
||||||
|
payload,
|
||||||
|
requireAuth: true,
|
||||||
|
);
|
||||||
|
if (response == null) return null;
|
||||||
|
return _parseResponse(response, label: "Renew Tenant Subscription");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Exception in renewTenantSubscription: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get list of industries (for tenant creation drop-down)
|
||||||
|
static Future<List<dynamic>?> getIndustries() async {
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(
|
||||||
|
ApiEndpoints.getIndustries,
|
||||||
|
requireAuth: false, // usually public
|
||||||
|
);
|
||||||
|
if (response == null) return null;
|
||||||
|
return _parseResponse(response, label: "Get Industries");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Exception in getIndustries: $e", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get currently active subscription for logged-in tenant/user
|
||||||
|
static Future<Map<String, dynamic>?> getCurrentSubscription() async {
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(
|
||||||
|
ApiEndpoints.getCurrentSubscription,
|
||||||
|
requireAuth: true, // likely requires auth
|
||||||
|
);
|
||||||
|
if (response == null) return null;
|
||||||
|
return _parseResponse(response, label: "Get Current Subscription");
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Exception in getCurrentSubscription: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Employee APIs ===
|
// === Employee APIs ===
|
||||||
/// Search employees by first name and last name only (not middle name)
|
/// Search employees by first name and last name only (not middle name)
|
||||||
/// Returns a list of up to 10 employee records matching the search string.
|
/// Returns a list of up to 10 employee records matching the search string.
|
||||||
|
|||||||
39
lib/helpers/services/payment_service.dart
Normal file
39
lib/helpers/services/payment_service.dart
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
|
||||||
|
class PaymentService {
|
||||||
|
/// Create a Razorpay order on backend
|
||||||
|
Future<Map<String, dynamic>?> createOrder(double amount) async {
|
||||||
|
try {
|
||||||
|
logSafe("🟢 Calling createPaymentOrder API with amount: ₹$amount");
|
||||||
|
final response = await ApiService.createPaymentOrder(amount);
|
||||||
|
logSafe("🧩 Raw response in PaymentService: $response");
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Error in createOrder: $e", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify Razorpay payment signature
|
||||||
|
Future<Map<String, dynamic>?> verifyPayment({
|
||||||
|
required String orderId,
|
||||||
|
required String paymentId,
|
||||||
|
required String signature,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
logSafe("🟢 Calling verifyPayment API...");
|
||||||
|
final response = await ApiService.verifyPayment(
|
||||||
|
orderId: orderId,
|
||||||
|
paymentId: paymentId,
|
||||||
|
signature: signature,
|
||||||
|
);
|
||||||
|
logSafe("✅ VerifyPayment API response: $response");
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("❌ Error in verifyPayment: $e", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import 'package:marco/helpers/services/api_endpoints.dart';
|
|||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
import 'package:marco/helpers/services/auth_service.dart';
|
import 'package:marco/helpers/services/auth_service.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
import 'package:marco/model/tenant/tenant_list_model.dart';
|
import 'package:marco/model/tenant/tenant_list_model.dart';
|
||||||
|
|
||||||
/// Abstract interface for tenant service functionality
|
/// Abstract interface for tenant service functionality
|
||||||
@ -130,7 +131,7 @@ class TenantService implements ITenantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔹 Register FCM token after tenant selection
|
// 🔹 Register FCM token after tenant selection
|
||||||
final fcmToken = LocalStorage.getFcmToken();
|
final fcmToken = LocalStorage.getFcmToken();
|
||||||
if (fcmToken?.isNotEmpty ?? false) {
|
if (fcmToken?.isNotEmpty ?? false) {
|
||||||
final success = await AuthService.registerDeviceToken(fcmToken!);
|
final success = await AuthService.registerDeviceToken(fcmToken!);
|
||||||
logSafe(
|
logSafe(
|
||||||
@ -160,4 +161,65 @@ class TenantService implements ITenantService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> createTenant(
|
||||||
|
Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
logSafe("🟢 Creating tenant: $payload");
|
||||||
|
final resp = await ApiService.createTenantSelf(payload);
|
||||||
|
logSafe("🧩 createTenant response: $resp");
|
||||||
|
return resp;
|
||||||
|
} catch (e, s) {
|
||||||
|
logSafe("❌ Exception in createTenant: $e\n$s", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> subscribeTenant(
|
||||||
|
Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
logSafe("🟢 Subscribing tenant: $payload");
|
||||||
|
final resp = await ApiService.subscribeTenant(payload);
|
||||||
|
logSafe("🧩 subscribeTenant response: $resp");
|
||||||
|
return resp;
|
||||||
|
} catch (e, s) {
|
||||||
|
logSafe("❌ Exception in subscribeTenant: $e\n$s", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> renewSubscription(
|
||||||
|
Map<String, dynamic> payload) async {
|
||||||
|
try {
|
||||||
|
logSafe("🟢 Renewing subscription: $payload");
|
||||||
|
final resp = await ApiService.renewTenantSubscription(payload);
|
||||||
|
logSafe("🧩 renewSubscription response: $resp");
|
||||||
|
return resp;
|
||||||
|
} catch (e, s) {
|
||||||
|
logSafe("❌ Exception in renewSubscription: $e\n$s",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>?> getIndustries() async {
|
||||||
|
try {
|
||||||
|
logSafe("🟢 Fetching industries via ApiService...");
|
||||||
|
|
||||||
|
// ApiService.getIndustries() directly returns a List<dynamic>
|
||||||
|
final response = await ApiService.getIndustries();
|
||||||
|
|
||||||
|
// No need to dig into response["data"], because response itself is a List
|
||||||
|
if (response == null || response.isEmpty) {
|
||||||
|
logSafe("💡 ! No industries found (empty list)");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely cast list of maps
|
||||||
|
return List<Map<String, dynamic>>.from(response);
|
||||||
|
} catch (e, s) {
|
||||||
|
logSafe("❌ Exception in getIndustries: $e\n$s", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,10 @@ import 'package:marco/view/directory/directory_main_screen.dart';
|
|||||||
import 'package:marco/view/expense/expense_screen.dart';
|
import 'package:marco/view/expense/expense_screen.dart';
|
||||||
import 'package:marco/view/document/user_document_screen.dart';
|
import 'package:marco/view/document/user_document_screen.dart';
|
||||||
import 'package:marco/view/tenant/tenant_selection_screen.dart';
|
import 'package:marco/view/tenant/tenant_selection_screen.dart';
|
||||||
|
import 'package:marco/view/payment/payment_screen.dart';
|
||||||
|
import 'package:marco/view/subscriptions/subscriptions_screen.dart';
|
||||||
|
import 'package:marco/view/tenant/tenant_create_screen.dart';
|
||||||
|
import 'package:marco/view/subscriptions/current_plan_screen.dart';
|
||||||
|
|
||||||
class AuthMiddleware extends GetMiddleware {
|
class AuthMiddleware extends GetMiddleware {
|
||||||
@override
|
@override
|
||||||
@ -90,6 +94,22 @@ getPageRoute() {
|
|||||||
name: '/dashboard/document-main-page',
|
name: '/dashboard/document-main-page',
|
||||||
page: () => UserDocumentsPage(),
|
page: () => UserDocumentsPage(),
|
||||||
middlewares: [AuthMiddleware()]),
|
middlewares: [AuthMiddleware()]),
|
||||||
|
// Payment
|
||||||
|
GetPage(name: '/payment', page: () => PaymentScreen()),
|
||||||
|
GetPage(
|
||||||
|
name: '/create-tenant',
|
||||||
|
page: () => const TenantCreateScreen(),
|
||||||
|
),
|
||||||
|
|
||||||
|
GetPage(
|
||||||
|
name: '/subscription',
|
||||||
|
page: () => SubscriptionScreen(),
|
||||||
|
),
|
||||||
|
|
||||||
|
GetPage(
|
||||||
|
name: '/subscription/current',
|
||||||
|
page: () => const CurrentPlanScreen(),
|
||||||
|
),
|
||||||
// Authentication
|
// Authentication
|
||||||
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
||||||
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import 'package:marco/helpers/services/api_endpoints.dart';
|
|||||||
import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
|
import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
import 'package:marco/helpers/widgets/wave_background.dart';
|
import 'package:marco/helpers/widgets/wave_background.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
enum LoginOption { email, otp }
|
enum LoginOption { email, otp }
|
||||||
|
|
||||||
@ -135,6 +136,13 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
option: LoginOption.otp,
|
option: LoginOption.otp,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_buildActionButton(
|
||||||
|
context,
|
||||||
|
label: "Subscribe",
|
||||||
|
icon: LucideIcons.indian_rupee,
|
||||||
|
option: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
context,
|
context,
|
||||||
label: "Request a Demo",
|
label: "Request a Demo",
|
||||||
@ -243,6 +251,10 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
shadowColor: Colors.black26,
|
shadowColor: Colors.black26,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (label == "Subscribe") {
|
||||||
|
Get.toNamed('/subscription'); // Navigate to Subscription screen
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (option == null) {
|
if (option == null) {
|
||||||
OrganizationFormBottomSheet.show(context);
|
OrganizationFormBottomSheet.show(context);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -316,6 +316,14 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
onTap: _onProfileTap,
|
onTap: _onProfileTap,
|
||||||
),
|
),
|
||||||
SizedBox(height: spacingHeight),
|
SizedBox(height: spacingHeight),
|
||||||
|
_menuItemRow(
|
||||||
|
icon: LucideIcons.indian_rupee,
|
||||||
|
label: 'Subscription',
|
||||||
|
onTap: _onSubscribeTap,
|
||||||
|
iconColor: Colors.redAccent,
|
||||||
|
textColor: Colors.redAccent,
|
||||||
|
),
|
||||||
|
SizedBox(height: spacingHeight),
|
||||||
_menuItemRow(
|
_menuItemRow(
|
||||||
icon: LucideIcons.settings,
|
icon: LucideIcons.settings,
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
@ -385,6 +393,11 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onSubscribeTap() {
|
||||||
|
Get.toNamed("/subscription/current");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void _onMpinTap() {
|
void _onMpinTap() {
|
||||||
final controller = Get.put(MPINController());
|
final controller = Get.put(MPINController());
|
||||||
if (hasMpin) controller.setChangeMpinMode();
|
if (hasMpin) controller.setChangeMpinMode();
|
||||||
|
|||||||
129
lib/view/payment/payment_screen.dart
Normal file
129
lib/view/payment/payment_screen.dart
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:marco/controller/payment/payment_controller.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
class PaymentScreen extends StatefulWidget {
|
||||||
|
final double amount;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
const PaymentScreen({
|
||||||
|
super.key,
|
||||||
|
this.amount = 0.0,
|
||||||
|
this.description = "No description",
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PaymentScreen> createState() => _PaymentScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaymentScreenState extends State<PaymentScreen> {
|
||||||
|
PaymentController? _controller;
|
||||||
|
bool _controllerOwned = false; // true if we created it locally
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// don't initialize controller here — we need context to try Provider.of()
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_controller == null) {
|
||||||
|
// Try to get from Provider (if app provides one)
|
||||||
|
try {
|
||||||
|
// If a provider exists, this returns it; if not, it throws.
|
||||||
|
_controller = Provider.of<PaymentController>(context, listen: false);
|
||||||
|
_controllerOwned = false;
|
||||||
|
} catch (e) {
|
||||||
|
// No provider: create our own controller and mark ownership
|
||||||
|
_controller = PaymentController();
|
||||||
|
_controllerOwned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// If we created the controller, dispose its resources (Razorpay listeners)
|
||||||
|
if (_controllerOwned) {
|
||||||
|
try {
|
||||||
|
_controller?.disposeController();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Accept arguments from Get.arguments fallback to widget fields
|
||||||
|
final args = (Get.arguments ?? {}) as Map<String, dynamic>;
|
||||||
|
final double finalAmount = (args['amount'] ?? widget.amount).toDouble();
|
||||||
|
final String finalDescription = args['description'] ?? widget.description;
|
||||||
|
final String orderId = args['orderId'] ?? '';
|
||||||
|
|
||||||
|
final controller = _controller!;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
"Payment",
|
||||||
|
style: TextStyle(color: Colors.black),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (orderId.isNotEmpty)
|
||||||
|
Text("Order ID: $orderId", style: const TextStyle(fontSize: 18)),
|
||||||
|
if (orderId.isNotEmpty) const SizedBox(height: 8),
|
||||||
|
Text("Description: $finalDescription",
|
||||||
|
style: const TextStyle(fontSize: 18)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text("Amount: ₹${finalAmount.toStringAsFixed(2)}",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
// Extract optional IDs passed via Get.arguments
|
||||||
|
final String tenantEnquireId =
|
||||||
|
(args['tenantEnquireId'] ?? '') as String;
|
||||||
|
final String planId = (args['planId'] ?? '') as String;
|
||||||
|
|
||||||
|
await controller.startPayment(
|
||||||
|
amount: finalAmount,
|
||||||
|
description: finalDescription,
|
||||||
|
context: context,
|
||||||
|
tenantEnquireId:
|
||||||
|
tenantEnquireId.isNotEmpty ? tenantEnquireId : null,
|
||||||
|
planId: planId.isNotEmpty ? planId : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 40, vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
"Pay Now",
|
||||||
|
style: TextStyle(fontSize: 18, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
lib/view/subscriptions/current_plan_screen.dart
Normal file
216
lib/view/subscriptions/current_plan_screen.dart
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
|
||||||
|
class CurrentPlanScreen extends StatefulWidget {
|
||||||
|
const CurrentPlanScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CurrentPlanScreen> createState() => _CurrentPlanScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurrentPlanScreenState extends State<CurrentPlanScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
Map<String, dynamic>? _plan;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchCurrentPlan();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchCurrentPlan() async {
|
||||||
|
try {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final resp = await ApiService.getCurrentSubscription();
|
||||||
|
logSafe("💡 Get Current Subscription Response: $resp");
|
||||||
|
if (resp == null || resp['success'] != true) {
|
||||||
|
setState(() {
|
||||||
|
_plan = null;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final data = resp['data'] ?? resp;
|
||||||
|
setState(() {
|
||||||
|
_plan = data is Map ? Map<String, dynamic>.from(data) : null;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e, s) {
|
||||||
|
logSafe("❌ Exception while fetching current plan: $e\n$s",
|
||||||
|
level: LogLevel.error);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaceholder() {
|
||||||
|
return Center(
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text("No active subscription",
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Get.toNamed('/subscription'),
|
||||||
|
child: const Text("View Plans"),
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: WidgetStateProperty.all(Colors.green),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text("Current Plan",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.black)),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new),
|
||||||
|
onPressed: () => Get.back(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _plan == null
|
||||||
|
? _buildPlaceholder()
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
elevation: 3,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_planDisplayName(_plan!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_plan!['description'] ?? '',
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text("Price: ",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[700])),
|
||||||
|
Text(
|
||||||
|
"${_currencySymbol(_plan!)}${_plan!['price'] ?? 0}",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
"Trial: ${_plan!['trialDays'] ?? 0} days",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_plan!['startedAt'] != null)
|
||||||
|
Text("Started: ${_plan!['startedAt']}",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey)),
|
||||||
|
if (_plan!['expiresAt'] != null)
|
||||||
|
Text("Expires: ${_plan!['expiresAt']}",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Features / modules (if present)
|
||||||
|
if (_plan!['features'] != null)
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: _buildFeaturesList(_plan!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// navigate to subscription page (renew flow)
|
||||||
|
// pass current plan id so subscription screen can preselect if needed
|
||||||
|
Get.toNamed('/subscription',
|
||||||
|
arguments: {'currentPlanId': _plan!['id']});
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 14)),
|
||||||
|
child: const Text("Renew Plan"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _planDisplayName(Map<String, dynamic> plan) {
|
||||||
|
return plan['planName'] ?? plan['name'] ?? 'Plan';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _currencySymbol(Map<String, dynamic> plan) {
|
||||||
|
try {
|
||||||
|
return plan['currency']?['symbol'] ?? '₹';
|
||||||
|
} catch (_) {
|
||||||
|
return '₹';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildFeaturesList(Map<String, dynamic> plan) {
|
||||||
|
final features = <String>[];
|
||||||
|
try {
|
||||||
|
final modules = plan['features']?['modules'] ?? {};
|
||||||
|
if (modules is Map) {
|
||||||
|
modules.forEach((k, v) {
|
||||||
|
if (v is Map && v['enabled'] == true) features.add(v['name'] ?? k);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final supports = plan['features']?['supports'] ?? {};
|
||||||
|
if (supports is Map) {
|
||||||
|
supports.forEach((k, v) {
|
||||||
|
if (v == true)
|
||||||
|
features.add(
|
||||||
|
k.toString().replaceAll(RegExp(r'([a-z])([A-Z])'), r'\1 \2'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (features.isEmpty) return [const SizedBox.shrink()];
|
||||||
|
return [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text("Included features",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...features.map((f) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(children: [
|
||||||
|
const Icon(Icons.check, size: 16, color: Colors.green),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(f))
|
||||||
|
]),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
210
lib/view/subscriptions/subscriptions_screen.dart
Normal file
210
lib/view/subscriptions/subscriptions_screen.dart
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/controller/subscriptions/subscription_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
|
class SubscriptionScreen extends StatelessWidget {
|
||||||
|
final SubscriptionController controller = Get.put(SubscriptionController());
|
||||||
|
|
||||||
|
SubscriptionScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'Subscription Plans',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new),
|
||||||
|
onPressed: () => Get.back(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildFrequencyTabs(),
|
||||||
|
Expanded(
|
||||||
|
child: Obx(() {
|
||||||
|
if (controller.isLoading.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (controller.plans.isEmpty) {
|
||||||
|
return const Center(child: Text("No Plans Available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
onRefresh: () =>
|
||||||
|
controller.fetchPlans(controller.selectedFrequency.value),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: controller.plans.map((plan) {
|
||||||
|
final features = _extractFeatures(plan);
|
||||||
|
final currency = plan['currency']?['symbol'] ?? '₹';
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 3,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleMedium(
|
||||||
|
plan['planName'] ?? 'Plan',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 25,
|
||||||
|
),
|
||||||
|
MySpacing.height(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
plan['description'] ?? '',
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Text(
|
||||||
|
"$currency${plan['price'] ?? 0}",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Text(
|
||||||
|
"Trial: ${plan['trialDays'] ?? 0} days",
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: features
|
||||||
|
.map((f) => Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.green),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
f,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
Get.toNamed('/create-tenant', arguments: {
|
||||||
|
'planId': plan['id'] ?? '',
|
||||||
|
'amount': plan['price'] ?? 0,
|
||||||
|
'planName':
|
||||||
|
plan['planName'] ?? 'Subscription',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
'Subscribe',
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Frequency Tab Bar ---
|
||||||
|
Widget _buildFrequencyTabs() {
|
||||||
|
return Obx(() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.blue[50],
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: controller.frequencies.map((freq) {
|
||||||
|
final isSelected = controller.selectedFrequency.value == freq;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => controller.fetchPlans(freq),
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? Colors.green : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? Colors.green : Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_capitalize(freq),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : Colors.black87,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper to extract feature names dynamically ---
|
||||||
|
List<String> _extractFeatures(Map<String, dynamic> plan) {
|
||||||
|
final features = <String>[];
|
||||||
|
try {
|
||||||
|
final modules = plan['features']?['modules'] ?? {};
|
||||||
|
modules.forEach((key, value) {
|
||||||
|
if (value is Map && value['enabled'] == true) {
|
||||||
|
features.add(value['name'] ?? key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final supports = plan['features']?['supports'] ?? {};
|
||||||
|
supports.forEach((k, v) {
|
||||||
|
if (v == true) {
|
||||||
|
features.add(
|
||||||
|
k.toString().replaceAll(RegExp(r'([a-z])([A-Z])'), r'\1 \2'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
print("Feature parse error: $e");
|
||||||
|
}
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalize(String str) =>
|
||||||
|
str.isEmpty ? '' : str[0].toUpperCase() + str.substring(1);
|
||||||
|
}
|
||||||
286
lib/view/tenant/tenant_create_screen.dart
Normal file
286
lib/view/tenant/tenant_create_screen.dart
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/tenant_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
|
||||||
|
class TenantCreateScreen extends StatefulWidget {
|
||||||
|
const TenantCreateScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TenantCreateScreen> createState() => _TenantCreateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TenantCreateScreenState extends State<TenantCreateScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _firstNameCtrl = TextEditingController();
|
||||||
|
final _lastNameCtrl = TextEditingController();
|
||||||
|
final _orgNameCtrl = TextEditingController();
|
||||||
|
final _emailCtrl = TextEditingController();
|
||||||
|
final _contactCtrl = TextEditingController();
|
||||||
|
final _billingCtrl = TextEditingController();
|
||||||
|
final _orgSizeCtrl = TextEditingController();
|
||||||
|
final _referenceCtrl = TextEditingController();
|
||||||
|
|
||||||
|
final TenantService _tenantService = TenantService();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _industries = [];
|
||||||
|
String? _selectedIndustryId;
|
||||||
|
bool _loadingIndustries = true;
|
||||||
|
|
||||||
|
late final String planId;
|
||||||
|
late final double amount;
|
||||||
|
late final String planName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final args = (Get.arguments ?? {}) as Map<String, dynamic>;
|
||||||
|
planId = args['planId'] ?? '';
|
||||||
|
amount = (args['amount'] ?? 0).toDouble();
|
||||||
|
planName = args['planName'] ?? 'Subscription';
|
||||||
|
_fetchIndustries();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchIndustries() async {
|
||||||
|
try {
|
||||||
|
setState(() => _loadingIndustries = true);
|
||||||
|
final list = await _tenantService.getIndustries();
|
||||||
|
setState(() {
|
||||||
|
_industries = (list ?? []).whereType<Map<String, dynamic>>().toList();
|
||||||
|
_loadingIndustries = false;
|
||||||
|
});
|
||||||
|
} catch (e, s) {
|
||||||
|
logSafe("❌ Failed to fetch industries: $e\n$s", level: LogLevel.error);
|
||||||
|
setState(() => _loadingIndustries = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_firstNameCtrl.dispose();
|
||||||
|
_lastNameCtrl.dispose();
|
||||||
|
_orgNameCtrl.dispose();
|
||||||
|
_emailCtrl.dispose();
|
||||||
|
_contactCtrl.dispose();
|
||||||
|
_billingCtrl.dispose();
|
||||||
|
_orgSizeCtrl.dispose();
|
||||||
|
_referenceCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
if (_selectedIndustryId == null || _selectedIndustryId!.isEmpty) {
|
||||||
|
Get.snackbar("Error", "Please select industry",
|
||||||
|
backgroundColor: Colors.red, colorText: Colors.white);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
"firstName": _firstNameCtrl.text.trim(),
|
||||||
|
"lastName": _lastNameCtrl.text.trim(),
|
||||||
|
"organizationName": _orgNameCtrl.text.trim(),
|
||||||
|
"email": _emailCtrl.text.trim(),
|
||||||
|
"contactNumber": _contactCtrl.text.trim(),
|
||||||
|
"billingAddress": _billingCtrl.text.trim(),
|
||||||
|
"organizationSize": _orgSizeCtrl.text.trim(),
|
||||||
|
"industryId": _selectedIndustryId,
|
||||||
|
"reference": _referenceCtrl.text.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final resp = await _tenantService.createTenant(payload);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
|
||||||
|
if (resp == null || resp['success'] == false) {
|
||||||
|
Get.snackbar("Error",
|
||||||
|
resp?['message'] ?? "Failed to create tenant. Try again later.",
|
||||||
|
backgroundColor: Colors.red, colorText: Colors.white);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = Map<String, dynamic>.from(resp['data'] ?? resp);
|
||||||
|
final tenantEnquireId =
|
||||||
|
data['tenantEnquireId'] ?? data['id'] ?? data['tenantId'];
|
||||||
|
|
||||||
|
if (tenantEnquireId == null) {
|
||||||
|
logSafe("❌ Missing tenant ID in response: $resp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Get.toNamed('/payment', arguments: {
|
||||||
|
'amount': amount,
|
||||||
|
'description': 'Subscription for ${_orgNameCtrl.text}',
|
||||||
|
'tenantEnquireId': tenantEnquireId,
|
||||||
|
'planId': planId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
resizeToAvoidBottomInset: true, // ensures scroll works with keyboard
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
"Create Tenant",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black),
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new),
|
||||||
|
onPressed: () => Get.back(),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text("Personal Info",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_buildTextField(
|
||||||
|
_firstNameCtrl, "First Name *", Icons.person,
|
||||||
|
validator: (v) => v!.isEmpty ? "Required" : null),
|
||||||
|
_buildTextField(_lastNameCtrl, "Last Name *",
|
||||||
|
Icons.person_outline,
|
||||||
|
validator: (v) => v!.isEmpty ? "Required" : null),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text("Organization",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_buildTextField(_orgNameCtrl, "Organization Name *",
|
||||||
|
Icons.business,
|
||||||
|
validator: (v) => v!.isEmpty ? "Required" : null),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_loadingIndustries
|
||||||
|
? const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
))
|
||||||
|
: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border:
|
||||||
|
Border.all(color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 2),
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
isExpanded: true,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
dropdownColor: Colors.white,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.keyboard_arrow_down_rounded),
|
||||||
|
value: _selectedIndustryId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixIcon:
|
||||||
|
Icon(Icons.apartment_outlined),
|
||||||
|
labelText: "Industry *",
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
items: _industries.map((itm) {
|
||||||
|
final id = itm['id']?.toString() ?? '';
|
||||||
|
final name = itm['name'] ??
|
||||||
|
itm['displayName'] ??
|
||||||
|
'Unknown';
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: id, child: Text(name));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _selectedIndustryId = v),
|
||||||
|
validator: (v) => v == null || v.isEmpty
|
||||||
|
? "Select industry"
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text("Contact Details",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_buildTextField(
|
||||||
|
_emailCtrl, "Email *", Icons.email_outlined,
|
||||||
|
validator: (v) => v == null || !v.contains('@')
|
||||||
|
? "Invalid email"
|
||||||
|
: null),
|
||||||
|
_buildTextField(
|
||||||
|
_contactCtrl, "Contact Number", Icons.phone),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text("Additional Info",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_buildTextField(_billingCtrl, "Billing Address",
|
||||||
|
Icons.home_outlined),
|
||||||
|
_buildTextField(_orgSizeCtrl, "Organization Size",
|
||||||
|
Icons.people_alt_outlined),
|
||||||
|
_buildTextField(_referenceCtrl, "Reference",
|
||||||
|
Icons.note_alt_outlined),
|
||||||
|
const Spacer(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? const CircularProgressIndicator(
|
||||||
|
color: Colors.white)
|
||||||
|
: const Text("Create Tenant & Continue",
|
||||||
|
style: TextStyle(fontSize: 16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(
|
||||||
|
TextEditingController controller, String label, IconData icon,
|
||||||
|
{String? Function(String?)? validator}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
validator: validator,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: Icon(icon),
|
||||||
|
labelText: label,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,8 @@ dependencies:
|
|||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
razorpay_flutter: 1.4.0
|
||||||
|
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
@ -145,3 +147,5 @@ flutter:
|
|||||||
#
|
#
|
||||||
# For details regarding fonts from package dependencies,
|
# For details regarding fonts from package dependencies,
|
||||||
# see https://flutter.dev/to/font-from-package
|
# see https://flutter.dev/to/font-from-package
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user