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 {
|
||||
// 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';
|
||||
}
|
||||
|
||||
@ -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 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) {
|
||||
_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.
|
||||
|
||||
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/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user