From f116c96578215ba80f031143c54bbe46c1d3c5d5 Mon Sep 17 00:00:00 2001 From: WaseemAbbasi22 <50428976+WaseemAbbasi22@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:59:21 +0300 Subject: [PATCH] site , signup and demo completed --- lib/controllers/api_routes/urls.dart | 11 + lib/extensions/context_extension.dart | 3 +- lib/extensions/widget_extensions.dart | 12 +- lib/main.dart | 15 +- .../applicationDemo/enums/otp_method.dart | 18 + .../application_demo_request_model.dart | 117 +++++++ .../applicationDemo/models/role_model.dart | 21 ++ .../application_demo_request_provider.dart | 222 +++++++++++++ .../screens/demo_request_form_screen.dart | 309 ++++++++++++++++++ .../widgets/otp_method_selection_dialog.dart | 76 +++++ .../otp_verification_bottom_sheet.dart | 175 ++++++++++ .../signup/providers/signup_provider.dart | 7 + lib/modules/signup/screens/signup_screen.dart | 109 ++++-- lib/new_views/pages/login_page.dart | 8 + 14 files changed, 1052 insertions(+), 51 deletions(-) create mode 100644 lib/modules/applicationDemo/enums/otp_method.dart create mode 100644 lib/modules/applicationDemo/models/application_demo_request_model.dart create mode 100644 lib/modules/applicationDemo/models/role_model.dart create mode 100644 lib/modules/applicationDemo/providers/application_demo_request_provider.dart create mode 100644 lib/modules/applicationDemo/screens/demo_request_form_screen.dart create mode 100644 lib/modules/applicationDemo/widgets/otp_method_selection_dialog.dart create mode 100644 lib/modules/applicationDemo/widgets/otp_verification_bottom_sheet.dart diff --git a/lib/controllers/api_routes/urls.dart b/lib/controllers/api_routes/urls.dart index 0e17718a..e62285cd 100644 --- a/lib/controllers/api_routes/urls.dart +++ b/lib/controllers/api_routes/urls.dart @@ -577,4 +577,15 @@ class URLs { static get generateReport => "$_baseUrl/ReportPDF/GenerateReport"; //4104 attachment type lookup + + // Demo Request APIs + static get getRoles => "$_baseUrl/Lookups/GetLookup?lookupEnum=4017"; // get roles for demo request + static get submitDemoRequest => "$_baseUrl/DemoRequest/CreateDemoRequest"; // post submit demo request + static get getDemoRequests => "$_baseUrl/DemoRequest/GetDemoRequests"; // get demo request history + + // Application Demo Request APIs + static get getApplicationDemoRoles => "$_baseUrl/Account/GetRolesByAssetGroupId?assetGroupId=6"; // get roles for application demo request + static get submitApplicationDemoRequest => "$_baseUrl/DemoUser/CreateDemoUser"; // post submit application demo request// get application demo request history + static get sendDemoUserOtp => "$_baseUrl/DemoUser/EmailVerification"; // post send OTP to user + static get verifyDemoUserOtp => "$_baseUrl/DemoUser/VerifyOtp"; // post verify OTP } diff --git a/lib/extensions/context_extension.dart b/lib/extensions/context_extension.dart index 41f93d7f..84d41a33 100644 --- a/lib/extensions/context_extension.dart +++ b/lib/extensions/context_extension.dart @@ -47,7 +47,8 @@ extension BuildContextExtension on BuildContext { context: this, useSafeArea: true, isScrollControlled: true, - isDismissible: true, + isDismissible: isDismissible ?? true, + enableDrag: isDismissible ?? true, backgroundColor: Colors.transparent, builder: (context) => SingleChildScrollView(padding: const EdgeInsets.all(0), child: childWidget.bottomSafeArea).bottomSheetContainerNew(context, title: title,showCancelButton: showCancelButton), ); diff --git a/lib/extensions/widget_extensions.dart b/lib/extensions/widget_extensions.dart index 2dc5cf00..66d4dde9 100644 --- a/lib/extensions/widget_extensions.dart +++ b/lib/extensions/widget_extensions.dart @@ -171,12 +171,12 @@ extension WidgetExtensions on Widget { title.bottomSheetHeadingTextStyle(context).expanded, if (showCancelButton) ...[ 8.width, - InkWell( - onTap: () => Navigator.pop(context), - child: Icon( - Icons.cancel_outlined, - color: context.isDarkNotListen ? AppColor.neutral30 : AppColor.black20, - ), + IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.close), + color: context.isDarkNotListen ? AppColor.neutral30 : AppColor.headingTextColor(context), + constraints: const BoxConstraints(), + onPressed: () => Navigator.pop(context), ) ] ], diff --git a/lib/main.dart b/lib/main.dart index 09a10f5a..1d0a0bab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -64,11 +64,10 @@ import 'package:test_sa/modules/signup/screens/signup_screen.dart'; import 'package:test_sa/modules/site_request_module/providers/site_request_provider.dart'; import 'package:test_sa/modules/site_request_module/screens/site_request_form_screen.dart'; import 'package:test_sa/modules/site_request_module/screens/site_request_history_screen.dart'; +import 'package:test_sa/modules/applicationDemo/providers/application_demo_request_provider.dart'; +import 'package:test_sa/modules/applicationDemo/screens/demo_request_form_screen.dart'; import 'package:test_sa/modules/tm_module/gas_refill/create_gas_refill_request.dart'; import 'package:test_sa/modules/tm_module/gas_refill/update_gas_refill_request.dart'; -import 'package:test_sa/modules/traf_module/create_traf_request_page.dart'; -import 'package:test_sa/modules/traf_module/traf_request_provider.dart'; -import 'package:test_sa/modules/traf_module/update_traf_request_page.dart'; import 'package:test_sa/new_views/app_style/app_themes.dart'; import 'package:test_sa/new_views/pages/help_center_page.dart'; import 'package:test_sa/new_views/pages/land_page/land_page.dart'; @@ -310,10 +309,9 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => PpmServiceProvider(), lazy: true), ChangeNotifierProvider(create: (_) => PpmChecklistStatusProvider(), lazy: true), - // TM MODULE PROVIDERS (11) + // TM MODULE PROVIDERS (10) ChangeNotifierProvider(create: (_) => DeviceTransferProvider(), lazy: true), ChangeNotifierProvider(create: (_) => AssetTransferStatusProvider(), lazy: true), - ChangeNotifierProvider(create: (_) => TrafRequestProvider(), lazy: true), ChangeNotifierProvider(create: (_) => TaskRequestProvider(), lazy: true), ChangeNotifierProvider(create: (_) => TaskTypeProvider(), lazy: true), ChangeNotifierProvider(create: (_) => TaskJobTypeOfAlertProvider(), lazy: true), @@ -358,6 +356,9 @@ class MyApp extends StatelessWidget { // SITE REQUEST MODULE PROVIDERS (1) ChangeNotifierProvider(create: (_) => SiteRequestProvider(), lazy: true), + // APPLICATION DEMO REQUEST MODULE PROVIDERS (1) + ChangeNotifierProvider(create: (_) => ApplicationDemoRequestProvider(), lazy: true), + // EMPLOYEE/USER PROVIDERS (2) ChangeNotifierProvider(create: (_) => NurseProvider(), lazy: true), ChangeNotifierProvider(create: (_) => UserSearchProvider(), lazy: true), @@ -412,10 +413,7 @@ class MyApp extends StatelessWidget { LandPage.routeName: (_) => const LandPage(), GasRefillRequestForm.routeName: (_) => const GasRefillRequestForm(), CreateTaskView.id: (_) => const CreateTaskView(), - CreateTRAFRequestPage.id: (_) => CreateTRAFRequestPage(), - UpdateTrafRequestPage.id: (_) => UpdateTrafRequestPage(), CreateGasRefillRequest.id: (_) => const CreateGasRefillRequest(), - UpdateGasRefillRequest.id: (_) => const UpdateGasRefillRequest(), CreateCMRequest.id: (_) => const CreateCMRequest(), MyAssetsPage.id: (_) => const MyAssetsPage(), SingleDepartmentPicker.id: (_) => const SingleDepartmentPicker(), @@ -441,6 +439,7 @@ class MyApp extends StatelessWidget { CreateDemoRequestPage.id: (_) => CreateDemoRequestPage(), CleanerSearchFormView.routeName: (_) => const CleanerSearchFormView(), CleanerSearchListView.routeName: (_) => const CleanerSearchListView(), + DemoRequestFormScreen.routeName: (_) => const DemoRequestFormScreen(), }, ), ), diff --git a/lib/modules/applicationDemo/enums/otp_method.dart b/lib/modules/applicationDemo/enums/otp_method.dart new file mode 100644 index 00000000..d3d9ee2a --- /dev/null +++ b/lib/modules/applicationDemo/enums/otp_method.dart @@ -0,0 +1,18 @@ +/// Enum for OTP delivery methods +enum OtpMethod { + sms('SMS'), + email('Email'); + + final String value; + + const OtpMethod(this.value); + + /// Get OTP method from string value + static OtpMethod fromString(String value) { + return OtpMethod.values.firstWhere( + (method) => method.value.toLowerCase() == value.toLowerCase(), + orElse: () => OtpMethod.sms, + ); + } +} + diff --git a/lib/modules/applicationDemo/models/application_demo_request_model.dart b/lib/modules/applicationDemo/models/application_demo_request_model.dart new file mode 100644 index 00000000..17139b4b --- /dev/null +++ b/lib/modules/applicationDemo/models/application_demo_request_model.dart @@ -0,0 +1,117 @@ +/// Model representing an application demo request +class ApplicationDemoRequestModel { + final String? id; + final String fullName; + final String emailAddress; + final String mobileNumber; + final String? companyName; + final int? companyId; + final List roleIds; + final List roleNames; + final String status; // Pending, Approved, Rejected + final DateTime requestDate; + + ApplicationDemoRequestModel({ + this.id, + required this.fullName, + required this.emailAddress, + required this.mobileNumber, + this.companyName, + this.companyId, + required this.roleIds, + required this.roleNames, + required this.status, + required this.requestDate, + }); + + /// Converts model to JSON for API submission + Map toJson() { + return { + 'id': id ?? 0, + 'fullName': fullName, + 'emailAddress': emailAddress, + 'mobileNumber': mobileNumber, + // 'companyName': companyId ?? 88, + 'companyId': 88, + 'demoRequestUserRoleIds': roleIds, + }; + } + + /// Creates model from API response JSON + factory ApplicationDemoRequestModel.fromJson(Map json) { + return ApplicationDemoRequestModel( + id: json['id']?.toString() ?? json['requestNumber']?.toString() ?? '', + fullName: json['fullName'] ?? '', + emailAddress: json['emailAddress'] ?? json['email'] ?? '', + mobileNumber: json['mobileNumber'] ?? json['mobile'] ?? '', + companyName: json['companyName'] ?? json['company'] ?? '', + companyId: json['companyId'], + roleIds: _parseList(json['roleIds'] ?? json['demoRequestUserRoleIds']), + roleNames: _parseList(json['roleNames'] ?? json['roles']), + status: json['status'] ?? 'Pending', + requestDate: _parseDate(json), + ); + } + + /// Helper method to parse list from various formats + static List _parseList(dynamic value) { + if (value == null) return []; + + if (value is List) { + return value.map((e) => e.toString()).toList(); + } + + if (value is String) { + if (value.isEmpty) return []; + return value.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); + } + + return []; + } + + /// Helper method to parse date from various formats + static DateTime _parseDate(Map json) { + final dateValue = json['requestDate'] ?? json['createdDate'] ?? json['date']; + + if (dateValue == null) return DateTime.now(); + + if (dateValue is String) { + try { + return DateTime.parse(dateValue); + } catch (e) { + return DateTime.now(); + } + } + + if (dateValue is DateTime) return dateValue; + + return DateTime.now(); + } + + /// Creates a copy with optional field updates + ApplicationDemoRequestModel copyWith({ + String? id, + String? fullName, + String? emailAddress, + String? mobileNumber, + String? companyName, + int? companyId, + List? roleIds, + List? roleNames, + String? status, + DateTime? requestDate, + }) { + return ApplicationDemoRequestModel( + id: id ?? this.id, + fullName: fullName ?? this.fullName, + emailAddress: emailAddress ?? this.emailAddress, + mobileNumber: mobileNumber ?? this.mobileNumber, + companyName: companyName ?? this.companyName, + companyId: companyId ?? this.companyId, + roleIds: roleIds ?? this.roleIds, + roleNames: roleNames ?? this.roleNames, + status: status ?? this.status, + requestDate: requestDate ?? this.requestDate, + ); + } +} diff --git a/lib/modules/applicationDemo/models/role_model.dart b/lib/modules/applicationDemo/models/role_model.dart new file mode 100644 index 00000000..6e2d6739 --- /dev/null +++ b/lib/modules/applicationDemo/models/role_model.dart @@ -0,0 +1,21 @@ +import 'package:test_sa/models/base.dart'; + +class ApplicationDemoRoleModel extends Base { + final int? value; + final String identifier; + + ApplicationDemoRoleModel({ + required String id, + required String name, + this.value, + }) : identifier = id.toString(), + super(identifier: id, name: name); + + factory ApplicationDemoRoleModel.fromJson(Map json) { + return ApplicationDemoRoleModel( + id: json['id'] ?? '', + name: json['name'] ?? '', + value: json['fixedName'] != null ? int.tryParse(json['fixedName'].toString()) : null, + ); + } +} diff --git a/lib/modules/applicationDemo/providers/application_demo_request_provider.dart b/lib/modules/applicationDemo/providers/application_demo_request_provider.dart new file mode 100644 index 00000000..19ed3842 --- /dev/null +++ b/lib/modules/applicationDemo/providers/application_demo_request_provider.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:test_sa/controllers/api_routes/urls.dart'; +import 'package:test_sa/modules/applicationDemo/models/application_demo_request_model.dart'; +import 'package:test_sa/modules/applicationDemo/models/role_model.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Provider for managing application demo request operations +class ApplicationDemoRequestProvider extends ChangeNotifier { + bool _loading = false; + bool _roleLoading = false; + List _roles = []; + String? _errorMessage; + String? _successMessage; + + // Getters + bool get loading => _loading; + + bool get roleLoading => _roleLoading; + + String? get errorMessage => _errorMessage; + + String? get successMessage => _successMessage; + + List get roles => _roles; + + // Setter + set loading(bool value) { + _loading = value; + notifyListeners(); + } + + set roleLoading(bool value) { + _loading = value; + notifyListeners(); + } + + /// Get headers without authentication token for public APIs + Map get _unauthenticatedHeaders => { + 'Content-Type': 'application/json', + 'X-Timezone-Offset': DateTime.now().timeZoneOffset.toString().split(".").first, + }; + + /// Fetches available roles from the server (without authentication) + Future fetchRoles() async { + if (roleLoading) return -2; + + roleLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final response = await http.get( + Uri.parse(URLs.getApplicationDemoRoles), + headers: _unauthenticatedHeaders, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final List responseData = jsonDecode(response.body); + _roles = responseData.map((json) => ApplicationDemoRoleModel.fromJson(json)).toList(); + + roleLoading = false; + notifyListeners(); + return response.statusCode; + } else { + final responseData = jsonDecode(response.body); + _errorMessage = responseData['message'] ?? 'Failed to fetch roles'; + roleLoading = false; + notifyListeners(); + return response.statusCode; + } + } catch (error) { + _errorMessage = 'Failed to fetch roles: $error'; + roleLoading = false; + notifyListeners(); + return -1; + } + } + + /// Submits an application demo request to the server (without authentication) + Future submitApplicationDemoRequest(ApplicationDemoRequestModel request) async { + loading = true; + _errorMessage = null; + _successMessage = null; + notifyListeners(); + + try { + final requestBody = request.toJson(); + + final response = await http.post( + Uri.parse(URLs.submitApplicationDemoRequest), + headers: _unauthenticatedHeaders, + body: json.encode(requestBody), + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final responseData = json.decode(response.body); + _successMessage = responseData['message'] ?? 'Your demo request has been submitted successfully'; + loading = false; + notifyListeners(); + return true; + } else { + final responseData = json.decode(response.body); + _errorMessage = responseData['message'] ?? 'Failed to submit request'; + loading = false; + notifyListeners(); + return false; + } + } catch (error) { + _errorMessage = 'Failed to submit request: $error'; + loading = false; + notifyListeners(); + return false; + } + } + + /// Sends OTP to user via email or SMS + Future sendOtp({ + required String fullName, + required String phoneNumber, + required String email, + required String typeSend, // "SMS" or "Email" + }) async { + loading = true; + _errorMessage = null; + _successMessage = null; + notifyListeners(); + + try { + final requestBody = { + 'fullName': fullName, + 'phoneNumber': phoneNumber, + 'email': email, + 'typeSend': typeSend, + }; + + final response = await http.post( + Uri.parse(URLs.sendDemoUserOtp), + headers: _unauthenticatedHeaders, + body: json.encode(requestBody), + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final responseData = json.decode(response.body); + _successMessage = responseData['message']; + loading = false; + notifyListeners(); + return true; + } else { + final responseData = json.decode(response.body); + _errorMessage = responseData['message'] ?? 'Failed to send OTP'; + loading = false; + notifyListeners(); + return false; + } + } catch (error) { + _errorMessage = 'Failed to send OTP: $error'; + loading = false; + notifyListeners(); + return false; + } + } + + /// Verifies OTP code + Future verifyOtp({ + String? email, + String? phoneNumber, + required String otpCode, + }) async { + loading = true; + _errorMessage = null; + _successMessage = null; + notifyListeners(); + + try { + final requestBody = { + 'otpCode': otpCode, + }; + + // Add email or phoneNumber based on what's provided + if (email != null && email.isNotEmpty) { + requestBody['email'] = email; + } + if (phoneNumber != null && phoneNumber.isNotEmpty) { + requestBody['phoneNumber'] = phoneNumber; + } + + final response = await http.post( + Uri.parse(URLs.verifyDemoUserOtp), + headers: _unauthenticatedHeaders, + body: json.encode(requestBody), + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final responseData = json.decode(response.body); + _successMessage = responseData['message']; + loading = false; + notifyListeners(); + return true; + } else { + final responseData = json.decode(response.body); + _errorMessage = responseData['message'] ?? 'Invalid OTP code'; + loading = false; + notifyListeners(); + return false; + } + } catch (error) { + _errorMessage = 'Failed to verify OTP: $error'; + loading = false; + notifyListeners(); + return false; + } + } + + /// Resets the provider state + void reset() { + _loading = false; + _roles.clear(); + _errorMessage = null; + notifyListeners(); + } +} diff --git a/lib/modules/applicationDemo/screens/demo_request_form_screen.dart b/lib/modules/applicationDemo/screens/demo_request_form_screen.dart new file mode 100644 index 00000000..40326b88 --- /dev/null +++ b/lib/modules/applicationDemo/screens/demo_request_form_screen.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:test_sa/controllers/validator/validator.dart'; +import 'package:test_sa/extensions/context_extension.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/modules/applicationDemo/enums/otp_method.dart'; +import 'package:test_sa/modules/applicationDemo/models/application_demo_request_model.dart'; +import 'package:test_sa/modules/applicationDemo/models/role_model.dart'; +import 'package:test_sa/modules/applicationDemo/providers/application_demo_request_provider.dart'; +import 'package:test_sa/modules/applicationDemo/widgets/otp_method_selection_dialog.dart'; +import 'package:test_sa/modules/applicationDemo/widgets/otp_verification_bottom_sheet.dart'; +import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/new_views/common_widgets/app_filled_button.dart'; +import 'package:test_sa/new_views/common_widgets/app_text_form_field.dart'; +import 'package:test_sa/new_views/common_widgets/custom_app_bar.dart'; +import 'package:test_sa/new_views/common_widgets/multiple_item_drop_down_menu.dart'; +import 'package:test_sa/providers/loading_list_notifier.dart'; + +class DemoRequestFormScreen extends StatefulWidget { + static const String routeName = '/demo_request_form'; + + const DemoRequestFormScreen({Key? key}) : super(key: key); + + @override + State createState() => _DemoRequestFormScreenState(); +} + +class _DemoRequestFormScreenState extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _mobileController = TextEditingController(); + final TextEditingController _companyNameController = TextEditingController(); + + List _selectedRoles = []; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + // Fetch roles when the screen initializes + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = Provider.of(context, listen: false); + provider.fetchRoles(); + }); + } + + @override + void dispose() { + _fullNameController.dispose(); + _emailController.dispose(); + _mobileController.dispose(); + _companyNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = Provider.of(context); + + return Scaffold( + backgroundColor: AppColor.background(context), + appBar: const CustomAppBar( + title: 'Demo Request', + ), + body: Form( + key: _formKey, + child: Column( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppTextFormField( + controller: _fullNameController, + labelText: 'Full Name', + hintText: 'Enter your full name', + textInputType: TextInputType.name, + validator: (value) { + if (value == null || value.isEmpty) return 'Full Name is required'; + return null; + }, + ), + 16.height, + AppTextFormField( + controller: _emailController, + labelText: 'Email Address', + hintText: 'Enter your email address', + textInputType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) return 'Email is required'; + if (!Validator.isEmail(value)) return 'Invalid email format'; + return null; + }, + ), + 16.height, + AppTextFormField( + controller: _mobileController, + labelText: 'Mobile Number', + hintText: 'Enter your mobile number', + textInputType: TextInputType.phone, + validator: (value) { + if (value == null || value.isEmpty) return 'Mobile Number is required'; + if (!Validator.isPhoneNumber(value)) return 'Invalid mobile number'; + return null; + }, + ), + 16.height, + AppTextFormField( + controller: _companyNameController, + labelText: 'Company Name', + hintText: 'Enter your company name', + textInputType: TextInputType.text, + validator: (value) { + if (value == null || value.isEmpty) return 'Company Name is required'; + return null; + }, + ), + 16.height, + MultipleItemDropDownMenu( + context: context, + title: 'Select Roles', + initialValue: _selectedRoles, + staticData: provider.roles, + loading: provider.roleLoading, + showAsFullScreenDialog: true, + onSelect: (selectedRoles) { + setState(() { + _selectedRoles = selectedRoles ?? []; + }); + }, + ), + ], + ).paddingAll(16).expanded, + FooterActionButton.footerContainer( + context: context, + child: AppFilledButton( + label: 'Submit', + buttonColor: AppColor.primary10, + maxWidth: true, + loading: _isSubmitting, + onPressed: _isSubmitting ? null : _submitRequest, + ), + ), + ], + ), + ), + ); + } + + Future _submitRequest() async { + // Validate form fields + if (!_formKey.currentState!.validate()) { + return; + } + if (_selectedRoles.isEmpty) { + context.showConfirmDialog('Please select at least one role'); + return; + } + showDialog( + context: context, + builder: (BuildContext context) { + return OtpMethodSelectionDialog( + onMethodSelected: (OtpMethod method) { + _submitRequestWithOtpMethod(method); + }, + ); + }, + ); + } + + Future _submitRequestWithOtpMethod(OtpMethod otpMethod) async { + setState(() { + _isSubmitting = true; + }); + + try { + final provider = Provider.of(context, listen: false); + final otpSent = await provider.sendOtp( + fullName: _fullNameController.text.trim(), + phoneNumber: _mobileController.text.trim(), + email: _emailController.text.trim(), + typeSend: otpMethod.value, + ); + + if (otpSent) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + await context.showBottomSheet( + OtpVerificationBottomSheet( + otpMethod: otpMethod.value, + onOtpVerified: (String otp) async { + await _verifyOtpAndComplete(otp, otpMethod); + }, + onResend: () async { + await provider.sendOtp( + fullName: _fullNameController.text.trim(), + phoneNumber: _mobileController.text.trim(), + email: _emailController.text.trim(), + typeSend: otpMethod.value, + ); + }, + onChangeMethod: () { + _submitRequest(); + }, + ), + isDismissible: false, + title: 'Verify OTP', + showCancelButton: true, + ); + } + } else { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + context.showConfirmDialog( + provider.errorMessage ?? 'Failed to send OTP. Please try again.', + ); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + context.showConfirmDialog('An error occurred: $e'); + } + } + } + + Future _verifyOtpAndComplete(String otp, OtpMethod otpMethod) async { + // Close the bottom sheet + Navigator.pop(context); + + // Show loading + setState(() { + _isSubmitting = true; + }); + + try { + final provider = Provider.of(context, listen: false); + + // Verify OTP - pass email or phoneNumber based on selected method + final otpVerified = await provider.verifyOtp( + email: otpMethod == OtpMethod.email ? _emailController.text.trim() : null, + phoneNumber: otpMethod == OtpMethod.sms ? _mobileController.text.trim() : null, + otpCode: otp, + ); + + if (otpVerified) { + // OTP verified, now submit the demo request + final request = ApplicationDemoRequestModel( + fullName: _fullNameController.text.trim(), + emailAddress: _emailController.text.trim(), + mobileNumber: _mobileController.text.trim(), + companyId: 0, + roleIds: _selectedRoles.map((role) => role.identifier ?? '').toList(), + roleNames: _selectedRoles.map((role) => role.name ?? '').toList(), + status: 'Pending', + requestDate: DateTime.now(), + ); + + final success = await provider.submitApplicationDemoRequest(request); + + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + if (success) { + context.showConfirmDialog( + provider.successMessage ?? 'Your demo request has been verified and submitted successfully. We will contact you soon!', + onTap: () { + Navigator.of(context).pop(); // Close dialog + Navigator.of(context).pop(); // Go back to login + }, + ); + } else { + context.showConfirmDialog( + provider.errorMessage ?? 'Failed to submit demo request. Please try again.', + ); + } + } + } else { + // OTP verification failed + if (mounted) { + setState(() { + _isSubmitting = false; + }); + context.showConfirmDialog( + provider.errorMessage ?? 'Invalid OTP code. Please try again.', + ); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + context.showConfirmDialog('OTP verification failed: $e'); + } + } + } +} diff --git a/lib/modules/applicationDemo/widgets/otp_method_selection_dialog.dart b/lib/modules/applicationDemo/widgets/otp_method_selection_dialog.dart new file mode 100644 index 00000000..d6646527 --- /dev/null +++ b/lib/modules/applicationDemo/widgets/otp_method_selection_dialog.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/modules/applicationDemo/enums/otp_method.dart'; +import 'package:test_sa/new_views/common_widgets/app_filled_button.dart'; +import 'package:test_sa/views/widgets/item_views/info_header_widget.dart'; + +class OtpMethodSelectionDialog extends StatelessWidget { + final Function(OtpMethod method) onMethodSelected; + + const OtpMethodSelectionDialog({ + Key? key, + required this.onMethodSelected, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(), + insetPadding: const EdgeInsets.only(left: 21, right: 21), + child: Padding( + padding: const EdgeInsets.only(left: 20, right: 20, top: 18, bottom: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: InfoHeader16Widget("Information").paddingOnly(top: 16), + ), + IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.close), + color: Colors.black87, + constraints: const BoxConstraints(), + onPressed: () => Navigator.pop(context), + ) + ], + ), + "Choose a method to receive the OTP".bodyText(context), + 28.height, + Row( + children: [ + Expanded( + child: AppFilledButton( + label: OtpMethod.sms.value, + onPressed: () { + Navigator.pop(context); + onMethodSelected(OtpMethod.sms); + }, + textColor: Colors.white, + ), + ), + 16.width, + Expanded( + child: AppFilledButton( + label: OtpMethod.email.value, + onPressed: () { + Navigator.pop(context); + onMethodSelected(OtpMethod.email); + }, + textColor: Colors.white, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/applicationDemo/widgets/otp_verification_bottom_sheet.dart b/lib/modules/applicationDemo/widgets/otp_verification_bottom_sheet.dart new file mode 100644 index 00000000..787b8d4e --- /dev/null +++ b/lib/modules/applicationDemo/widgets/otp_verification_bottom_sheet.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:pinput/pinput.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/views/widgets/item_views/info_header_widget.dart'; +import 'package:test_sa/views/widgets/item_views/info_text_widget.dart'; + +class OtpVerificationBottomSheet extends StatefulWidget { + final String otpMethod; + final Function(String otp) onOtpVerified; + final VoidCallback? onResend; + final VoidCallback? onChangeMethod; + + const OtpVerificationBottomSheet({ + Key? key, + required this.otpMethod, + required this.onOtpVerified, + this.onResend, + this.onChangeMethod, + }) : super(key: key); + + @override + State createState() => _OtpVerificationBottomSheetState(); +} + +class _OtpVerificationBottomSheetState extends State { + final ValueNotifier _timerNotifier = ValueNotifier("2:00"); + bool _canResend = false; + + @override + void initState() { + super.initState(); + _startTimer(); + } + + void _startTimer() { + int seconds = 120; // 2 minutes + _canResend = false; + + Future.doWhile(() async { + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return false; + + seconds--; + if (seconds <= 0) { + _timerNotifier.value = "0:00"; + _canResend = true; + return false; + } + + final minutes = seconds ~/ 60; + final secs = seconds % 60; + _timerNotifier.value = "$minutes:${secs.toString().padLeft(2, '0')}"; + return true; + }); + } + + @override + void dispose() { + _timerNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final defaultPinTheme = PinTheme( + width: 51.toScreenWidth, + height: 63.toScreenHeight, + textStyle: TextStyle(fontSize: 24, color: AppColor.headingTextColor(context)), + decoration: BoxDecoration( + color: AppColor.background(context), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColor.border2Color(context)), + ), + ); + + return WillPopScope( + onWillPop: () async => false, // Prevent dismiss by back button + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InfoTextWidget( + label: 'OTP has been sent to your ${widget.otpMethod.toLowerCase()}', + isMsg: true, + showEmptyValue: true, + ), + Center( + child: Pinput( + length: 4, + defaultPinTheme: defaultPinTheme, + focusedPinTheme: defaultPinTheme.copyWith( + decoration: defaultPinTheme.decoration?.copyWith( + border: Border.all(color: AppColor.primary10), + ), + ), + onCompleted: (pin) async { + // Call the verification callback + widget.onOtpVerified(pin); + }, + ), + ).paddingOnly(top: 24, bottom: 24), + Row( + children: [ + InfoTextWidget(label: 'Resend in', isMsg: true, showEmptyValue: true), + 6.width, + ValueListenableBuilder( + valueListenable: _timerNotifier, + builder: (context, value, _) { + return InfoTextWidget(label: value, isMsg: true, showEmptyValue: true); + }, + ), + 6.width, + InkWell( + onTap: _canResend + ? () { + if (widget.onResend != null) { + widget.onResend!(); + _startTimer(); + } + } + : null, + child: Text( + 'Resend', + style: TextStyle( + color: _canResend ? AppColor.primary10 : AppColor.white50, + fontWeight: FontWeight.w500, + fontSize: 12.toScreenWidth, + decorationColor: _canResend ? AppColor.primary10 : AppColor.white50, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ], + ).toShadowContainer(context), + 16.height, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + InfoHeader16Widget('Having trouble receiving OTP?'), + InkWell( + onTap: () { + Navigator.pop(context); + if (widget.onChangeMethod != null) { + widget.onChangeMethod!(); + } + }, + child: Text( + 'Check out other methods', + style: TextStyle( + color: AppColor.primary10, + fontSize: 16.toScreenWidth, + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + decorationColor: AppColor.primary10, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/signup/providers/signup_provider.dart b/lib/modules/signup/providers/signup_provider.dart index 74cc9fdd..257ecadb 100644 --- a/lib/modules/signup/providers/signup_provider.dart +++ b/lib/modules/signup/providers/signup_provider.dart @@ -29,6 +29,9 @@ class SignUpProvider extends ChangeNotifier { String? _errorMessage; String? get errorMessage => _errorMessage; + String? _successMessage; + String? get successMessage => _successMessage; + /// Get headers without authentication token for signup APIs Map get _unauthenticatedHeaders => { 'Content-Type': 'application/json', @@ -79,6 +82,7 @@ class SignUpProvider extends ChangeNotifier { loading = true; _errorMessage = null; + _successMessage = null; notifyListeners(); try { @@ -89,6 +93,8 @@ class SignUpProvider extends ChangeNotifier { ); if (response.statusCode >= 200 && response.statusCode < 300) { + final data = json.decode(response.body); + _successMessage = data['message'] ?? 'Your signup request has been submitted successfully.'; loading = false; notifyListeners(); return true; @@ -192,6 +198,7 @@ class SignUpProvider extends ChangeNotifier { _sites = []; _departments = []; _errorMessage = null; + _successMessage = null; notifyListeners(); } } diff --git a/lib/modules/signup/screens/signup_screen.dart b/lib/modules/signup/screens/signup_screen.dart index 7c910d25..a5d1f341 100644 --- a/lib/modules/signup/screens/signup_screen.dart +++ b/lib/modules/signup/screens/signup_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:test_sa/controllers/validator/validator.dart'; import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; import 'package:test_sa/modules/signup/models/company_model.dart'; import 'package:test_sa/modules/signup/models/group_model.dart'; import 'package:test_sa/modules/signup/models/signup_request_model.dart'; @@ -203,10 +204,10 @@ class _SignUpScreenState extends State { _buildReadOnlyField("Mobile Number", _mobileController), 16.height, _buildExtensionField(), - 16.height, - _buildSiteDropdown(), - 16.height, - _buildDepartmentDropdown(), + // 16.height, + // _buildSiteDropdown(), + // 16.height, + // _buildDepartmentDropdown(), 16.height, _buildRoleField(), ], @@ -343,9 +344,8 @@ class _SignUpScreenState extends State { context: context, title: "Site", initialValue: _selectedSite, - showAsFullScreenDialog: false, - showAsBottomSheet: true, - requestById: _selectedAssetGroup?.id, + showAsFullScreenDialog: true, + requestById: _selectedAssetGroup?.id, onSelect: (value) { setState(() { _selectedSite = value; @@ -364,8 +364,8 @@ class _SignUpScreenState extends State { context: context, title: "Department", initialValue: _selectedDepartment, - showAsFullScreenDialog: false, - showAsBottomSheet: true, + showAsFullScreenDialog: true, + // showAsBottomSheet: true, requestById: _selectedAssetGroup?.id, onSelect: (value) { setState(() { @@ -406,8 +406,7 @@ class _SignUpScreenState extends State { if (employee != null) { // Check if the error message indicates duplicate account - if (_signUpProvider.errorMessage != null && - _signUpProvider.errorMessage!.toLowerCase().contains('already')) { + if (_signUpProvider.errorMessage != null && _signUpProvider.errorMessage!.toLowerCase().contains('already')) { // Show duplicate account popup _showDuplicateAccountDialog(); setState(() { @@ -429,8 +428,8 @@ class _SignUpScreenState extends State { // Check if it's a duplicate account error if (_signUpProvider.errorMessage != null && (_signUpProvider.errorMessage!.toLowerCase().contains('already') || - _signUpProvider.errorMessage!.toLowerCase().contains('exists') || - _signUpProvider.errorMessage!.toLowerCase().contains('account'))) { + _signUpProvider.errorMessage!.toLowerCase().contains('exists') || + _signUpProvider.errorMessage!.toLowerCase().contains('account'))) { _showDuplicateAccountDialog(); setState(() { _employeeFetched = false; @@ -509,16 +508,16 @@ class _SignUpScreenState extends State { } // Site is mandatory for HMG - if (_selectedSite == null) { - Fluttertoast.showToast(msg: "Site is required"); - return; - } - - // Department is mandatory for HMG - if (_selectedDepartment == null) { - Fluttertoast.showToast(msg: "Department is required"); - return; - } + // if (_selectedSite == null) { + // Fluttertoast.showToast(msg: "Site is required"); + // return; + // } + // + // // Department is mandatory for HMG + // if (_selectedDepartment == null) { + // Fluttertoast.showToast(msg: "Department is required"); + // return; + // } } else { // Non-HMG specific validations @@ -639,22 +638,60 @@ class _SignUpScreenState extends State { } void _showSuccessDialog() { + final successMessage = _signUpProvider.successMessage ?? 'Your signup request has been submitted successfully'; + showDialog( context: context, - builder: (context) => AlertDialog( - backgroundColor: AppColor.background(context), - title: "Success".bodyText(context), - content: "Your signup request has been submitted successfully. It will be reviewed by the Human Resources Admin." - .bodyText(context), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context); // Close dialog - Navigator.pop(context); // Go back to login - }, - child: "OK".bodyText(context).custom(color: AppColor.primary10), + builder: (context) => Dialog( + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(), + insetPadding: const EdgeInsets.only(left: 21, right: 21), + child: Padding( + padding: const EdgeInsets.only(left: 20, right: 20, top: 18, bottom: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + "Success", + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Colors.black87, + height: 35 / 24, + letterSpacing: -0.96, + ), + ).paddingOnly(top: 16), + ), + IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.close), + color: Colors.black87, + constraints: const BoxConstraints(), + onPressed: () { + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Go back to login + }, + ) + ], + ), + successMessage.bodyText(context), + 28.height, + AppFilledButton( + label: "OK", + onPressed: () { + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Go back to login + }, + textColor: Colors.white, + ), + ], ), - ], + ), ), ); } diff --git a/lib/new_views/pages/login_page.dart b/lib/new_views/pages/login_page.dart index 7e3f88c2..54e62748 100644 --- a/lib/new_views/pages/login_page.dart +++ b/lib/new_views/pages/login_page.dart @@ -16,6 +16,7 @@ import 'package:test_sa/models/new_models/general_response_model.dart'; import 'package:test_sa/modules/cx_module/chat/chat_page.dart'; import 'package:test_sa/modules/cx_module/survey/survey_page.dart'; import 'package:test_sa/modules/signup/screens/signup_screen.dart'; +import 'package:test_sa/modules/applicationDemo/screens/demo_request_form_screen.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; import 'package:test_sa/new_views/forget_password_module/forget_passwod_verify_otp.dart'; import 'package:test_sa/new_views/pages/land_page/land_page.dart'; @@ -236,8 +237,15 @@ class _LoginPageState extends State { "Sign Up".bodyText(context).custom(color: AppColor.primary10, fontWeight: FontWeight.w600).onPress(() { Navigator.pushNamed(context, SignUpScreen.routeName); }), + " or ".bodyText(context).custom(color: context.isDark ? AppColor.primary50 : AppColor.neutral50), + "Request a Demo".bodyText(context).custom(color: AppColor.primary10, fontWeight: FontWeight.w600).onPress(() { + Navigator.pushNamed(context, DemoRequestFormScreen.routeName); + }), ], ), + // Demo Request Button + + 24.height, ], ), ),