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});
@ -26,57 +29,88 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> with UIMixi
init: controller,
builder: (controller) {
return Form(
key: controller.basicValidator.formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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.",
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 your email and we'll send you instructions to reset your password.",
fontWeight: 600,
xMuted: true),
MySpacing.height(12),
TextFormField(
xMuted: true,
),
MySpacing.height(12),
TextFormField(
validator: controller.basicValidator.getValidation('email'),
controller: controller.basicValidator.getController('email'),
keyboardType: TextInputType.emailAddress,
style: MyTextStyle.labelMedium(),
decoration: InputDecoration(
labelText: "Email Address",
labelStyle: MyTextStyle.bodySmall(xMuted: true),
border: OutlineInputBorder(borderSide: BorderSide.none),
filled: true,
fillColor: contentTheme.secondary.withAlpha(36),
prefixIcon: Icon(LucideIcons.mail, size: 16),
contentPadding: MySpacing.all(15),
isDense: true,
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
keyboardType: TextInputType.emailAddress,
style: MyTextStyle.labelMedium(),
decoration: InputDecoration(
labelText: "Email Address",
labelStyle: MyTextStyle.bodySmall(xMuted: true),
border: OutlineInputBorder(borderSide: BorderSide.none),
filled: true,
fillColor: theme.cardColor,
prefixIcon: Icon(LucideIcons.mail, size: 16),
contentPadding: MySpacing.all(15),
isDense: true,
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
),
MySpacing.height(20),
Center(
child: MyButton.rounded(
onPressed: controller.onLogin,
elevation: 0,
padding: MySpacing.xy(20, 16),
backgroundColor: contentTheme.primary,
child: MyText.labelMedium('Forgot Password', color: contentTheme.onPrimary),
MySpacing.height(20),
Center(
child: MyButton.rounded(
onPressed: controller.onForgotPassword,
elevation: 0,
padding: MySpacing.xy(20, 16),
backgroundColor: Colors.blueAccent,
child: MyText.labelMedium(
'Send Reset Link',
color: contentTheme.onPrimary,
),
),
),
),
Center(
child: MyButton.text(
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),
Center(
child: MyButton.text(
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,
),
),
),
),
],
));
],
),
),
),
);
},
),
);

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,