Booking physical consultation , bugs fixed and Profile picture adding using firebase storage is complete. Co-authored-by: Jipson George <152465898+Jipson-cosq@users.noreply.github.com> Reviewed-on: cosqnet/telemednet#6 Co-authored-by: DhanshCOSQ <dhanshas@cosq.net> Co-committed-by: DhanshCOSQ <dhanshas@cosq.net>
681 lines
24 KiB
Dart
681 lines
24 KiB
Dart
import 'package:medora/route/route_names.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
import 'dart:io';
|
|
import '../../../controllers/patient_controller.dart';
|
|
import '../../../widgets/alert_screen.dart';
|
|
|
|
class PatientRegistrationScreen extends StatefulWidget {
|
|
const PatientRegistrationScreen({super.key});
|
|
|
|
@override
|
|
State<PatientRegistrationScreen> createState() =>
|
|
_PatientRegistrationScreenState();
|
|
}
|
|
|
|
class _PatientRegistrationScreenState extends State<PatientRegistrationScreen> {
|
|
final PatientController _controller = PatientController();
|
|
final TextEditingController _nameController = TextEditingController();
|
|
final TextEditingController _phoneController = TextEditingController();
|
|
bool _hasErrors = false;
|
|
final Map<String, String> _errors = {};
|
|
|
|
String? _gender;
|
|
DateTime? _dateOfBirth;
|
|
File? _image;
|
|
final ImagePicker _picker = ImagePicker();
|
|
String _selectedCountryCode = '+1';
|
|
|
|
final List<String> _countryCodes = ['+1', '+91', '+44', '+61', '+81'];
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_nameController.text = _controller.model.name ?? '';
|
|
if (_controller.model.phoneNumber != null) {
|
|
String phoneNumber = _controller.model.phoneNumber!;
|
|
if (phoneNumber.startsWith('+')) {
|
|
for (String code in _countryCodes) {
|
|
if (phoneNumber.startsWith(code)) {
|
|
_selectedCountryCode = code;
|
|
_phoneController.text = phoneNumber.substring(code.length);
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
_phoneController.text = phoneNumber;
|
|
}
|
|
}
|
|
|
|
_gender = _controller.model.gender;
|
|
_dateOfBirth = _controller.model.dateOfBirth;
|
|
if (_controller.model.profileImagePath != null) {
|
|
_image = File(_controller.model.profileImagePath!);
|
|
}
|
|
_updateCombinedPhoneNumber(_phoneController.text);
|
|
}
|
|
|
|
Future<void> _getImage(ImageSource source) async {
|
|
final XFile? pickedFile = await _picker.pickImage(source: source);
|
|
|
|
if (pickedFile != null) {
|
|
setState(() {
|
|
_image = File(pickedFile.path);
|
|
});
|
|
_controller.updateProfileImage(pickedFile.path);
|
|
}
|
|
}
|
|
|
|
void _updateCombinedPhoneNumber(String phoneNumber) {
|
|
String cleanPhoneNumber = phoneNumber.replaceAll(RegExp(r'^\+\d{1,3}'), '');
|
|
String fullPhoneNumber = '$_selectedCountryCode$cleanPhoneNumber';
|
|
_controller.updatePhoneNumber(fullPhoneNumber);
|
|
}
|
|
|
|
void _showImageSourceActionSheet(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (BuildContext context) {
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16),
|
|
child: Text(
|
|
'Select Image Source',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(Icons.photo_library, color: Colors.blue),
|
|
),
|
|
title: const Text('Choose from Gallery'),
|
|
onTap: () {
|
|
_getImage(ImageSource.gallery);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(Icons.photo_camera, color: Colors.blue),
|
|
),
|
|
title: const Text('Take a Photo'),
|
|
onTap: () {
|
|
_getImage(ImageSource.camera);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showResultDialog(bool isSuccess) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => AlertScreen(
|
|
arguments: AlertArguments(
|
|
title: isSuccess ? 'Thank You' : 'Oops!',
|
|
message: isSuccess
|
|
? 'Profile created successfully!'
|
|
: 'Failed to create profile. Please try again.',
|
|
actionTitle: isSuccess ? 'Go to Dashboard' : 'Try Again',
|
|
type: isSuccess ? AlertType.success : AlertType.error,
|
|
onActionPressed: () {
|
|
Navigator.pop(context);
|
|
if (isSuccess) {
|
|
Navigator.pushReplacementNamed(
|
|
context,
|
|
RouteNames.patientDashboardScreen,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.grey[50],
|
|
appBar: AppBar(
|
|
elevation: 0,
|
|
backgroundColor: Colors.white,
|
|
title: const Text(
|
|
'Create Profile',
|
|
style: TextStyle(color: Colors.black),
|
|
),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: IconButton(
|
|
onPressed: () {
|
|
if (_validateAllFields()) {
|
|
_controller.savePatientData();
|
|
_showResultDialog(true);
|
|
} else {
|
|
_showValidationErrors();
|
|
}
|
|
},
|
|
icon: const Icon(Icons.check, color: Colors.blue, weight: 50),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
color: Colors.white,
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () => _showImageSourceActionSheet(context),
|
|
child: Stack(
|
|
alignment: Alignment.bottomRight,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.blueGrey.withOpacity(0.5),
|
|
blurRadius: 5,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.blue, width: 2),
|
|
),
|
|
child: CircleAvatar(
|
|
radius: 75,
|
|
backgroundImage:
|
|
_image != null ? FileImage(_image!) : null,
|
|
child: _image == null
|
|
? const Icon(Icons.person,
|
|
size: 50, color: Colors.blue)
|
|
: null,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.blueGrey.withOpacity(0.5),
|
|
blurRadius: 5,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
color: Colors.blue,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
),
|
|
child: const Icon(Icons.camera_alt,
|
|
size: 20, color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.blueGrey.withOpacity(0.5),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
_buildUniformField(
|
|
label: 'Name',
|
|
icon: Icons.person_outline,
|
|
child: TextField(
|
|
controller: _nameController,
|
|
onChanged: (value) => _controller.updateName(value),
|
|
decoration: const InputDecoration(
|
|
border: InputBorder.none,
|
|
hintText: 'Enter your name',
|
|
),
|
|
),
|
|
),
|
|
_buildUniformField(
|
|
label: 'Phone Number',
|
|
icon: Icons.phone_outlined,
|
|
child: Row(
|
|
children: [
|
|
DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _selectedCountryCode,
|
|
onChanged: (String? newValue) {
|
|
if (newValue != null) {
|
|
setState(() {
|
|
_selectedCountryCode = newValue;
|
|
});
|
|
|
|
_updateCombinedPhoneNumber(
|
|
_phoneController.text);
|
|
}
|
|
},
|
|
items:
|
|
_countryCodes.map<DropdownMenuItem<String>>(
|
|
(String code) {
|
|
return DropdownMenuItem<String>(
|
|
value: code,
|
|
child: Text(code),
|
|
);
|
|
},
|
|
).toList(),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _phoneController,
|
|
onChanged: (value) {
|
|
_updateCombinedPhoneNumber(value);
|
|
},
|
|
keyboardType: TextInputType.phone,
|
|
decoration: const InputDecoration(
|
|
border: InputBorder.none,
|
|
hintText: 'Enter your phone number',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildUniformField(
|
|
label: 'Gender',
|
|
icon: Icons.people_outline,
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _gender,
|
|
isExpanded: true,
|
|
hint: const Text('Select gender'),
|
|
onChanged: (value) {
|
|
setState(() => _gender = value);
|
|
_controller.updateGender(value!);
|
|
},
|
|
items: ['Male', 'Female', 'Other']
|
|
.map<DropdownMenuItem<String>>((String value) {
|
|
return DropdownMenuItem<String>(
|
|
value: value,
|
|
child: Text(value),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
_buildUniformField(
|
|
label: 'Date of Birth',
|
|
icon: Icons.calendar_today_outlined,
|
|
child: InkWell(
|
|
onTap: () async {
|
|
final DateTime? picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _dateOfBirth ??
|
|
DateTime.now()
|
|
.subtract(const Duration(days: 365 * 18)),
|
|
firstDate: DateTime(1900),
|
|
lastDate: DateTime.now()
|
|
.subtract(const Duration(days: 365 * 18)),
|
|
builder: (context, child) {
|
|
return Theme(
|
|
data: Theme.of(context).copyWith(
|
|
colorScheme: const ColorScheme.light(
|
|
primary: Colors.blue),
|
|
),
|
|
child: child!,
|
|
);
|
|
},
|
|
);
|
|
if (picked != null && picked != _dateOfBirth) {
|
|
setState(() => _dateOfBirth = picked);
|
|
_controller.updateDateOfBirth(picked);
|
|
}
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Text(
|
|
_dateOfBirth != null
|
|
? DateFormat('dd/MM/yyyy').format(_dateOfBirth!)
|
|
: 'Select date of birth',
|
|
style: TextStyle(
|
|
color: _dateOfBirth != null
|
|
? Colors.black87
|
|
: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.blueGrey.withOpacity(0.5),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_buildNavigationField(
|
|
'Address',
|
|
Icons.location_on,
|
|
() async {
|
|
final result = await Navigator.pushNamed(
|
|
context,
|
|
RouteNames.patientAdressScreen,
|
|
arguments: _controller,
|
|
);
|
|
if (result == true) {
|
|
setState(() {});
|
|
}
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
_buildNavigationField(
|
|
'Family Members',
|
|
Icons.family_restroom_outlined,
|
|
() => Navigator.pushNamed(
|
|
context,
|
|
RouteNames.patientFamilyMembersScreen,
|
|
arguments: _controller,
|
|
),
|
|
),
|
|
],
|
|
)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildUniformField({
|
|
required String label,
|
|
required IconData icon,
|
|
required Widget child,
|
|
String? errorKey,
|
|
}) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: _errors.containsKey(errorKey ?? '')
|
|
? Colors.red
|
|
: Colors.grey.shade200,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16, top: 8),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: _errors.containsKey(errorKey ?? '')
|
|
? Colors.red
|
|
: Colors.grey[600],
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 20,
|
|
color: _errors.containsKey(errorKey ?? '')
|
|
? Colors.red
|
|
: Colors.blue,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: child),
|
|
],
|
|
),
|
|
),
|
|
if (_errors.containsKey(errorKey ?? ''))
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
|
child: Text(
|
|
_errors[errorKey]!,
|
|
style: const TextStyle(
|
|
color: Colors.red,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _validateAllFields() {
|
|
setState(() {
|
|
_errors.clear();
|
|
_hasErrors = false;
|
|
|
|
final name = _nameController.text.trim();
|
|
if (name.isEmpty) {
|
|
_errors['name'] = 'Name is required';
|
|
_hasErrors = true;
|
|
} else if (name.length < 2 &&
|
|
RegExp(r'^[A-Za-z]+([.\s]?[A-Za-z]+)*$').hasMatch(name)) {
|
|
_errors['name'] = 'Name must be at least 2 characters';
|
|
_hasErrors = true;
|
|
}
|
|
|
|
final phoneNumber = _phoneController.text.trim();
|
|
if (phoneNumber.isEmpty) {
|
|
_errors['phone'] = 'Phone number is required';
|
|
_hasErrors = true;
|
|
} else if (!RegExp(r'^\d{10}$').hasMatch(phoneNumber)) {
|
|
_errors['phone'] = 'Enter a valid 10-digit phone number';
|
|
_hasErrors = true;
|
|
}
|
|
|
|
if (_gender == null) {
|
|
_errors['gender'] = 'Please select a gender';
|
|
_hasErrors = true;
|
|
}
|
|
|
|
if (_dateOfBirth == null) {
|
|
_errors['dob'] = 'Date of Birth is required';
|
|
_hasErrors = true;
|
|
} else {
|
|
final age = DateTime.now().difference(_dateOfBirth!).inDays ~/ 365;
|
|
if (age < 18) {
|
|
_errors['dob'] = 'User must be at least 18 years old';
|
|
_hasErrors = true;
|
|
}
|
|
}
|
|
|
|
if (_image == null) {
|
|
_errors['image'] = 'Profile picture is required';
|
|
_hasErrors = true;
|
|
}
|
|
|
|
final address = _controller.model.address;
|
|
if (address.houseNo?.isEmpty ?? true) {
|
|
_errors['address'] = 'Please complete all required address fields';
|
|
_hasErrors = true;
|
|
}
|
|
|
|
if (address.addressType == 'Other' &&
|
|
(address.otherLabel?.isEmpty ?? true)) {
|
|
_errors['address'] = 'Please specify other address label';
|
|
_hasErrors = true;
|
|
}
|
|
});
|
|
|
|
return !_hasErrors;
|
|
}
|
|
|
|
void _showValidationErrors() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: Colors.red),
|
|
SizedBox(width: 8),
|
|
Text('Validation Errors'),
|
|
],
|
|
),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: _errors.entries
|
|
.map((error) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Text(
|
|
'• ${error.value}',
|
|
style: const TextStyle(color: Colors.red),
|
|
),
|
|
))
|
|
.toList(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('OK'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNavigationField(
|
|
String label, IconData icon, VoidCallback onTap) {
|
|
bool isAddressField = label == 'Address';
|
|
bool hasAddressError = _errors.containsKey('address');
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: (isAddressField && hasAddressError)
|
|
? Colors.red.withOpacity(0.1)
|
|
: Colors.blue.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon,
|
|
color: (isAddressField && hasAddressError)
|
|
? Colors.red
|
|
: Colors.blue,
|
|
size: 24),
|
|
),
|
|
title: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color: (isAddressField && hasAddressError)
|
|
? Colors.red
|
|
: Colors.black,
|
|
),
|
|
),
|
|
subtitle: isAddressField ? _buildAddressSubtitle() : null,
|
|
trailing: Icon(
|
|
Icons.chevron_right,
|
|
color:
|
|
(isAddressField && hasAddressError) ? Colors.red : Colors.blue,
|
|
),
|
|
onTap: onTap,
|
|
),
|
|
if (isAddressField && hasAddressError)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
|
child: Text(
|
|
_errors['address']!,
|
|
style: const TextStyle(
|
|
color: Colors.red,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAddressSubtitle() {
|
|
final address = _controller.model.address;
|
|
if (address.houseNo == null ||
|
|
address.line == null ||
|
|
address.city == null) {
|
|
return const Text(
|
|
'No address added',
|
|
style: TextStyle(color: Colors.grey),
|
|
);
|
|
}
|
|
|
|
return Text(
|
|
'${address.houseNo}, ${address.line}\n'
|
|
'${address.city}, ${address.state} ${address.pincode}\n'
|
|
'${address.addressType}${address.addressType == "Other" ? ": ${address.otherLabel}" : ""}',
|
|
style: const TextStyle(color: Colors.black87),
|
|
);
|
|
}
|
|
}
|