Compare commits

...

28 Commits

Author SHA1 Message Date
9cf0016ccc change color of 'create tenant & continue' button 2025-11-04 17:19:03 +05:30
3008a6ab79 UI updated of Subscription module 2025-11-04 16:37:56 +05:30
e674d18542 current plan screen implmentation 2025-11-03 12:11:34 +05:30
3ad3515d8d fixed routing 2025-11-01 16:52:14 +05:30
32d74d23dd added import get package 2025-11-01 16:37:55 +05:30
79c34a62d7 revert 7e64e8c091ff4eee842f023d312c3687ac288fd7
revert change theme ui
2025-11-01 10:59:47 +00:00
b0b68d688e revert 4aaf8a6bf1db345351c44b5136e4ca9f67da1834
revert Merge branch 'feature/payment' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into feature/payment
2025-11-01 10:58:30 +00:00
4aaf8a6bf1 Merge branch 'feature/payment' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into feature/payment 2025-11-01 16:17:45 +05:30
21adc5e556 change create tenant ui and added indudtry dropdown 2025-11-01 16:10:39 +05:30
7e427237c3 implement create tender screen 2025-11-01 16:10:39 +05:30
12fbef19c5 changes in payment screen UI 2025-11-01 16:10:39 +05:30
c556df1f80 implemented razorpay payment successfully 2025-11-01 16:10:38 +05:30
70fcc2e662 subscription 2025-11-01 16:10:22 +05:30
2d95e92edb implementation of subscription module 2025-11-01 16:10:22 +05:30
8415b6549d implementation of payment feature 2025-11-01 16:09:08 +05:30
6c0f325a44 implementation of payment feature 2025-11-01 16:08:30 +05:30
ff9c712a7e implementation of payment feature 2025-11-01 16:08:29 +05:30
7e64e8c091 change theme ui 2025-11-01 16:07:54 +05:30
c5ebf17dbc change create tenant ui and added indudtry dropdown 2025-11-01 15:14:00 +05:30
9dfdac0323 implement create tender screen 2025-10-31 16:57:38 +05:30
ea82d65a81 changes in payment screen UI 2025-10-31 10:57:50 +05:30
8f15a04c88 implemented razorpay payment successfully 2025-10-30 17:23:00 +05:30
4a1bd85435 subscription 2025-10-30 16:02:14 +05:30
f70138238b implementation of subscription module 2025-10-29 14:03:41 +05:30
d071fa6c39 implementation of payment feature 2025-10-27 16:45:23 +05:30
27de1a5306 implementation of payment feature 2025-10-27 15:40:44 +05:30
dad3d98896 implementation of payment feature 2025-10-27 11:55:40 +05:30
1d8ec14a87 change theme ui 2025-10-24 16:54:13 +05:30
14 changed files with 1628 additions and 50 deletions

View 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,
);
}
}

View 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;
}
}
}

View File

@ -1,6 +1,6 @@
class ApiEndpoints {
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
static const String baseUrl = "https://api.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://devapi.marcoaiot.com/api";
// Dashboard Module API Endpoints
@ -100,4 +100,15 @@ class ApiEndpoints {
static const getAllOrganizations = "/organization/list";
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';
}

View File

@ -75,10 +75,17 @@ class ApiService {
return token;
}
static Map<String, String> _headers(String token) => {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
static Map<String, String> _headers(String? token) {
final headers = {
'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) {
if (enableLogs) logSafe(message);
@ -86,16 +93,26 @@ class ApiService {
static dynamic _parseResponse(http.Response response, {String label = ''}) {
_log("$label Response: ${response.body}");
try {
final json = jsonDecode(response.body);
if (response.statusCode == 200 && json['success'] == true) {
return json['data'];
final Map<String, dynamic> json = jsonDecode(response.body);
// Treat 200299 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) {
_log("Response parsing error [$label]: $e");
return null;
}
return null;
}
static dynamic _parseResponseForAllData(http.Response response,
@ -123,32 +140,49 @@ class ApiService {
String endpoint, {
Map<String, String>? queryParams,
bool hasRetried = false,
bool requireAuth = true,
}) async {
String? token = await _getToken();
if (token == null) {
logSafe("Token is null. Forcing logout from GET request.",
level: LogLevel.error);
await LocalStorage.logout();
return null;
// Allow public (no-login) API calls for subscription & payment
final isPublicEndpoint =
endpoint.contains("/subscription") || endpoint.contains("/payment");
String? token;
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")
.replace(queryParameters: queryParams);
logSafe("Initiating GET request", level: LogLevel.debug);
logSafe("🌐 Initiating GET request", level: LogLevel.debug);
logSafe("URL: $uri", level: LogLevel.debug);
logSafe("Query Parameters: ${queryParams ?? {}}", level: LogLevel.debug);
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
try {
final response = await http
.get(uri, headers: _headers(token))
.get(
uri,
headers: (isPublicEndpoint || !requireAuth)
? _headers(null)
: _headers(token),
)
.timeout(extendedTimeout);
logSafe("Response Status: ${response.statusCode}", 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...",
level: LogLevel.warning);
@ -159,6 +193,7 @@ class ApiService {
endpoint,
queryParams: queryParams,
hasRetried: true,
requireAuth: requireAuth,
);
}
@ -176,33 +211,68 @@ class ApiService {
static Future<http.Response?> _postRequest(
String endpoint,
dynamic body, {
Duration customTimeout = extendedTimeout,
dynamic data, {
// <-- changed from Map<String, dynamic> to dynamic
Map<String, String>? queryParams,
bool hasRetried = false,
bool requireAuth = true,
Duration? customTimeout,
}) async {
String? token = await _getToken();
if (token == null) return null;
// Allow public (no-login) API calls for subscription & payment
final isPublicEndpoint =
endpoint.contains("/subscription") || endpoint.contains("/payment");
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
logSafe(
"POST $uri\nHeaders: ${_headers(token)}\nBody: $body",
);
String? token = await _getToken();
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 {
final response = await http
.post(uri, headers: _headers(token), body: jsonEncode(body))
.timeout(customTimeout);
.post(
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("Unauthorized POST. Attempting token refresh...");
logSafe("Response ${response.statusCode}: ${response.body}",
level: LogLevel.debug);
// Retry token refresh for private routes only
if (response.statusCode == 401 &&
!hasRetried &&
requireAuth &&
!isPublicEndpoint) {
if (await AuthService.refreshToken()) {
return await _postRequest(endpoint, body,
customTimeout: customTimeout, hasRetried: true);
return await _postRequest(
endpoint,
data,
queryParams: queryParams,
hasRetried: true,
requireAuth: requireAuth,
customTimeout: customTimeout,
);
}
await LocalStorage.logout();
}
return response;
} catch (e) {
logSafe("HTTP POST Exception: $e", level: LogLevel.error);
logSafe("HTTP POST Exception: $e", level: LogLevel.error);
return null;
}
}
@ -213,16 +283,20 @@ class ApiService {
Map<String, String>? additionalHeaders,
Duration customTimeout = extendedTimeout,
bool hasRetried = false,
bool requireAuth = true, // added
}) async {
String? token = await _getToken();
if (token == null) return null;
String? token;
if (requireAuth) {
token = await _getToken();
if (token == null) return null;
}
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
logSafe(
"PUT $uri\nHeaders: ${_headers(token)}\nBody: $body",
);
final headers = {
..._headers(token),
'Content-Type': 'application/json',
if (requireAuth && token != null) ..._headers(token),
if (additionalHeaders != null) ...additionalHeaders,
};
@ -235,19 +309,23 @@ class ApiService {
.put(uri, headers: headers, body: jsonEncode(body))
.timeout(customTimeout);
if (response.statusCode == 401 && !hasRetried) {
logSafe("Unauthorized PUT. Attempting token refresh...");
if (response.statusCode == 401 && requireAuth && !hasRetried) {
logSafe("⚠️ Unauthorized PUT. Attempting token refresh...");
if (await AuthService.refreshToken()) {
return await _putRequest(endpoint, body,
additionalHeaders: additionalHeaders,
customTimeout: customTimeout,
hasRetried: true);
return await _putRequest(
endpoint,
body,
additionalHeaders: additionalHeaders,
customTimeout: customTimeout,
hasRetried: true,
requireAuth: requireAuth,
);
}
}
return response;
} catch (e) {
logSafe("HTTP PUT Exception: $e", level: LogLevel.error);
logSafe("HTTP PUT Exception: $e", level: LogLevel.error);
return null;
}
}
@ -1957,6 +2035,53 @@ class ApiService {
? _parseResponseForAllData(res, label: 'Contact Bucket List')
: 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 ===
static Future<List<dynamic>?> getProjects() async =>
@ -2085,6 +2210,135 @@ class ApiService {
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 ===
/// 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.

View 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;
}
}
}

View File

@ -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/app_logger.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';
/// Abstract interface for tenant service functionality
@ -130,7 +131,7 @@ class TenantService implements ITenantService {
}
// 🔹 Register FCM token after tenant selection
final fcmToken = LocalStorage.getFcmToken();
final fcmToken = LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!);
logSafe(
@ -160,4 +161,65 @@ class TenantService implements ITenantService {
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;
}
}
}

View File

@ -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/document/user_document_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 {
@override
@ -90,6 +94,22 @@ getPageRoute() {
name: '/dashboard/document-main-page',
page: () => UserDocumentsPage(),
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
GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),

View File

@ -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/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
import 'package:get/get.dart';
enum LoginOption { email, otp }
@ -135,6 +136,13 @@ class _WelcomeScreenState extends State<WelcomeScreen>
option: LoginOption.otp,
),
const SizedBox(height: 16),
_buildActionButton(
context,
label: "Subscribe",
icon: LucideIcons.indian_rupee,
option: null,
),
const SizedBox(height: 16),
_buildActionButton(
context,
label: "Request a Demo",
@ -243,6 +251,10 @@ class _WelcomeScreenState extends State<WelcomeScreen>
shadowColor: Colors.black26,
),
onPressed: () {
if (label == "Subscribe") {
Get.toNamed('/subscription'); // Navigate to Subscription screen
return;
}
if (option == null) {
OrganizationFormBottomSheet.show(context);
} else {

View File

@ -316,6 +316,14 @@ class _UserProfileBarState extends State<UserProfileBar>
onTap: _onProfileTap,
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.indian_rupee,
label: 'Subscription',
onTap: _onSubscribeTap,
iconColor: Colors.redAccent,
textColor: Colors.redAccent,
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.settings,
label: 'Settings',
@ -385,6 +393,11 @@ class _UserProfileBarState extends State<UserProfileBar>
));
}
void _onSubscribeTap() {
Get.toNamed("/subscription/current");
}
void _onMpinTap() {
final controller = Get.put(MPINController());
if (hasMpin) controller.setChangeMpinMode();

View 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),
),
),
),
],
),
),
);
}
}

View 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))
]),
)),
];
}
}

View 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);
}

View 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),
),
),
),
);
}
}

View File

@ -33,6 +33,8 @@ dependencies:
flutter_localizations:
sdk: flutter
razorpay_flutter: 1.4.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@ -145,3 +147,5 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package