Implement forgot password functionality and enhance UI in the authentication flow

This commit is contained in:
Vaibhav Surve 2025-05-29 13:11:27 +05:30
parent 915471f4c0
commit 8b01161448
6 changed files with 524 additions and 52 deletions

View File

@ -4,7 +4,7 @@ import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_validators.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class ForgotPasswordController extends MyController {
MyFormValidator basicValidator = MyFormValidator();
bool showPassword = false;
@ -35,6 +35,31 @@ class ForgotPasswordController extends MyController {
}
}
/// New: Forgot password function
Future<void> onForgotPassword() async {
if (basicValidator.validateForm()) {
update();
final data = basicValidator.getData();
final email = data['email']?.toString() ?? '';
final result = await AuthService.forgotPassword(email);
if (result != null) {
showAppSnackbar(
title: "Success",
message: "Your password reset link was sent.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Success",
message: "Your password reset link was sent.",
type: SnackbarType.success,
);
}
update();
}
}
void gotoLogIn() {
Get.toNamed('/auth/login');
}

View File

@ -112,4 +112,89 @@ class AuthService {
return false;
}
}
// Forgot password API
static Future<Map<String, String>?> forgotPassword(String email) async {
final requestBody = {"email": email};
logger.i("Sending forgot password request with email: $email");
try {
final response = await http.post(
Uri.parse("$_baseUrl/auth/forgot-password"),
headers: _headers,
body: jsonEncode(requestBody),
);
logger.i(
"Forgot password API response (${response.statusCode}): ${response.body}");
final responseData = jsonDecode(response.body);
if (response.statusCode == 200 && responseData['success'] == true) {
logger.i("Forgot password request successful.");
return null;
} else {
return {
"error":
responseData['message'] ?? "Failed to send password reset link."
};
}
} catch (e) {
logger.e("Exception during forgot password request: $e");
return {"error": "Network error. Please check your connection."};
}
}
// Request demo API
static Future<Map<String, String>?> requestDemo(
Map<String, dynamic> demoData) async {
try {
final response = await http.post(
Uri.parse("$_baseUrl/market/inquiry"),
headers: _headers,
body: jsonEncode(demoData),
);
final responseData = jsonDecode(response.body);
if (response.statusCode == 200 && responseData['success'] == true) {
logger.i("Request Demo submitted successfully.");
return null;
} else {
return {
"error": responseData['message'] ?? "Failed to submit demo request."
};
}
} catch (e) {
logger.e("Exception during request demo: $e");
return {"error": "Network error. Please check your connection."};
}
}
static Future<List<Map<String, dynamic>>?> getIndustries() async {
try {
final response = await http.get(
Uri.parse("$_baseUrl/market/industries"),
headers: _headers,
);
logger.i(
"Get Industries API response (${response.statusCode}): ${response.body}");
final responseData = jsonDecode(response.body);
if (response.statusCode == 200 && responseData['success'] == true) {
// Return the list of industries as List<Map<String, dynamic>>
final List<dynamic> industriesData = responseData['data'];
return industriesData.cast<Map<String, dynamic>>();
} else {
logger.w("Failed to fetch industries: ${responseData['message']}");
return null;
}
} catch (e) {
logger.e("Exception during getIndustries: $e");
return null;
}
}
}

View File

@ -8,6 +8,9 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/view/layouts/auth_layout.dart';
import 'package:marco/images.dart';
import 'package:marco/helpers/theme/app_theme.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@ -27,16 +30,37 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> with UIMixi
builder: (controller) {
return Form(
key: controller.basicValidator.formKey,
child: SingleChildScrollView(
padding: MySpacing.xy(2, 40),
child: Container(
width: double.infinity,
padding: MySpacing.all(24),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.02),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: contentTheme.primary.withOpacity(0.5),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Image.asset(
Images.logoDark,
height: 120,
fit: BoxFit.contain,
),
),
MySpacing.height(10),
MyText.titleLarge("Forgot Password", fontWeight: 600),
MySpacing.height(12),
MyText.bodyMedium(
"Enter the email address associated with your account and we'll send an email instructions on how to recover your password.",
"Enter your email and we'll send you instructions to reset your password.",
fontWeight: 600,
xMuted: true),
xMuted: true,
),
MySpacing.height(12),
TextFormField(
validator: controller.basicValidator.getValidation('email'),
@ -48,7 +72,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> with UIMixi
labelStyle: MyTextStyle.bodySmall(xMuted: true),
border: OutlineInputBorder(borderSide: BorderSide.none),
filled: true,
fillColor: contentTheme.secondary.withAlpha(36),
fillColor: theme.cardColor,
prefixIcon: Icon(LucideIcons.mail, size: 16),
contentPadding: MySpacing.all(15),
isDense: true,
@ -59,11 +83,14 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> with UIMixi
MySpacing.height(20),
Center(
child: MyButton.rounded(
onPressed: controller.onLogin,
onPressed: controller.onForgotPassword,
elevation: 0,
padding: MySpacing.xy(20, 16),
backgroundColor: contentTheme.primary,
child: MyText.labelMedium('Forgot Password', color: contentTheme.onPrimary),
backgroundColor: Colors.blueAccent,
child: MyText.labelMedium(
'Send Reset Link',
color: contentTheme.onPrimary,
),
),
),
Center(
@ -71,12 +98,19 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> with UIMixi
onPressed: controller.gotoLogIn,
elevation: 0,
padding: MySpacing.x(16),
splashColor: contentTheme.secondary.withValues(alpha:0.1),
child: MyText.labelMedium('Back to log in', color: contentTheme.secondary),
splashColor:
contentTheme.secondary.withValues(alpha: 0.1),
child: MyText.labelMedium(
'Back to log in',
color: contentTheme.secondary,
),
),
),
],
));
),
),
),
);
},
),
);

View File

@ -10,6 +10,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/view/layouts/auth_layout.dart';
import 'package:marco/images.dart';
import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@ -194,7 +195,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
elevation: 2,
padding: MySpacing.xy(24, 16),
borderRadiusAll: 16,
backgroundColor: contentTheme.primary,
backgroundColor: Colors.blueAccent,
child: MyText.labelMedium(
'Login',
fontWeight: 600,
@ -207,10 +208,13 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
/// Register Link
Center(
child: MyButton.text(
onPressed: controller.gotoRegister,
onPressed: () {
OrganizationFormBottomSheet.show(context);
},
elevation: 0,
padding: MySpacing.xy(12, 8),
splashColor: contentTheme.secondary.withAlpha(30),
splashColor:
contentTheme.secondary.withAlpha(30),
child: MyText.bodySmall(
"Request a Demo",
color: contentTheme.secondary,

View File

@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class OrganizationFormBottomSheet {
static void show(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return _OrganizationForm(scrollController: scrollController);
},
),
);
}
}
class _OrganizationForm extends StatefulWidget {
final ScrollController scrollController;
const _OrganizationForm({required this.scrollController});
@override
State<_OrganizationForm> createState() => _OrganizationFormState();
}
class _OrganizationFormState extends State<_OrganizationForm> {
final MyFormValidator validator = MyFormValidator();
List<Map<String, dynamic>> _industries = [];
String? _selectedIndustryId;
final List<String> _sizes = [
'1-10',
'11-50',
'51-200',
'201-1000',
'1000+',
];
final Map<String, String> _sizeApiMap = {
'1-10': 'less than 10',
'11-50': '11 to 50',
'51-200': '51 to 200',
'201-1000': 'more than 200',
'1000+': 'more than 1000',
};
String? _selectedSize;
bool _agreed = false;
@override
void initState() {
super.initState();
_loadIndustries();
validator.addField<String>('organizationName',
required: true, controller: TextEditingController());
validator.addField<String>('email',
required: true, controller: TextEditingController());
validator.addField<String>('contactPerson',
required: true, controller: TextEditingController());
validator.addField<String>('contactNumber',
required: true, controller: TextEditingController());
validator.addField<String>('about', controller: TextEditingController());
validator.addField<String>('address',
required: true, controller: TextEditingController());
}
Future<void> _loadIndustries() async {
final industries = await AuthService.getIndustries();
if (industries != null) {
setState(() {
_industries = industries;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
controller: widget.scrollController,
child: Form(
key: validator.formKey,
child: Column(
children: [
Container(
width: 40,
height: 5,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(10),
),
),
Text(
'Adventure starts here 🚀',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
const Text("Make your app management easy and fun!"),
const SizedBox(height: 20),
_buildTextField('organizationName', 'Organization Name'),
_buildTextField('email', 'Email',
keyboardType: TextInputType.emailAddress),
_buildTextField('contactPerson', 'Contact Person'),
_buildTextField('contactNumber', 'Contact Number',
keyboardType: TextInputType.phone),
_buildTextField('about', 'About Organization'),
_buildTextField('address', 'Current Address'),
const SizedBox(height: 10),
_buildPopupMenuField(
'Organization Size',
_sizes,
_selectedSize,
(val) => setState(() => _selectedSize = val),
'Please select organization size',
),
_buildPopupMenuField(
'Industry',
_industries.map((e) => e['name'] as String).toList(),
_selectedIndustryId != null
? _industries.firstWhere(
(e) => e['id'] == _selectedIndustryId)['name']
: null,
(val) {
setState(() {
final selectedIndustry = _industries.firstWhere(
(element) => element['name'] == val,
orElse: () => {});
_selectedIndustryId = selectedIndustry['id'];
});
},
'Please select industry',
),
const SizedBox(height: 10),
Row(
children: [
Checkbox(
value: _agreed,
onChanged: (val) => setState(() => _agreed = val ?? false),
fillColor: MaterialStateProperty.all(Colors.white),
checkColor: Colors.white,
side: MaterialStateBorderSide.resolveWith(
(states) =>
BorderSide(color: Colors.blueAccent, width: 2),
),
),
const Expanded(
child: Text('I agree to privacy policy & terms')),
],
),
const SizedBox(height: 10),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
),
onPressed: _submitForm,
child: const Text("Submit"),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Back to login"),
),
],
),
),
),
);
}
Widget _buildPopupMenuField(
String label,
List<String> items,
String? selectedValue,
ValueChanged<String?> onSelected,
String errorText,
) {
final bool hasError = selectedValue == null;
final GlobalKey _key = GlobalKey();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: FormField<String>(
validator: (value) => hasError ? errorText : null,
builder: (fieldState) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(fontSize: 12, color: Colors.grey[700])),
const SizedBox(height: 6),
GestureDetector(
key: _key,
onTap: () async {
final RenderBox renderBox =
_key.currentContext!.findRenderObject() as RenderBox;
final Offset offset = renderBox.localToGlobal(Offset.zero);
final Size size = renderBox.size;
final selected = await showMenu<String>(
context: fieldState.context,
position: RelativeRect.fromLTRB(
offset.dx,
offset.dy + size.height,
offset.dx + size.width,
offset.dy,
),
items: items
.map((item) => PopupMenuItem<String>(
value: item,
child: Text(item),
))
.toList(),
);
if (selected != null) {
onSelected(selected);
fieldState.didChange(selected);
}
},
child: InputDecorator(
decoration: InputDecoration(
border: const OutlineInputBorder(),
errorText: fieldState.errorText,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
),
child: Text(
selectedValue ?? 'Select $label',
style: TextStyle(
color: selectedValue == null ? Colors.grey : Colors.black,
fontSize: 16,
),
),
),
),
],
);
},
),
);
}
Widget _buildTextField(String fieldName, String label,
{TextInputType keyboardType = TextInputType.text}) {
final controller = validator.getController(fieldName);
final validatorFunc = validator.getValidation<String>(fieldName);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
controller: controller,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
validator: validatorFunc,
),
);
}
void _submitForm() async {
bool isValid = validator.validateForm();
if (_selectedSize == null || _selectedIndustryId == null) {
isValid = false;
setState(() {});
}
if (!_agreed) {
isValid = false;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please agree to the privacy policy & terms')),
);
}
if (isValid) {
final formData = validator.getData();
final Map<String, dynamic> requestBody = {
'organizatioinName': formData['organizationName'],
'email': formData['email'],
'about': formData['about'],
'contactNumber': formData['contactNumber'],
'contactPerson': formData['contactPerson'],
'industryId': _selectedIndustryId ?? '',
'oragnizationSize': _sizeApiMap[_selectedSize] ?? '',
'terms': _agreed,
'address': formData['address'],
};
final error = await AuthService.requestDemo(requestBody);
if (error == null) {
showAppSnackbar(
title: "Success",
message: "Demo request submitted successfully!.",
type: SnackbarType.success,
);
Navigator.pop(context);
} else {
showAppSnackbar(
title: "Success",
message: (error['error'] ?? 'Unknown error'),
type: SnackbarType.success,
);
}
}
}
}

View File

@ -80,7 +80,7 @@ class _LeftBarState extends State<LeftBar>
children: [
Center(
child: Padding(
padding: MySpacing.fromLTRB(0, 24, 0, 0),
padding: EdgeInsets.only(top: 50),
child: InkWell(
onTap: () => Get.toNamed('/home'),
child: Image.asset(
@ -106,7 +106,6 @@ class _LeftBarState extends State<LeftBar>
physics: BouncingScrollPhysics(),
clipBehavior: Clip.antiAliasWithSaveLayer,
children: [
Divider(),
labelWidget("Dashboard"),
NavigationItem(
iconData: LucideIcons.layout_dashboard,