feature/medora-provider-497 #1
@ -14,7 +14,7 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.cosqnet.telemednet"
|
namespace = "com.cosqnet.medoraprovider"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = "25.1.8937393"
|
ndkVersion = "25.1.8937393"
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.cosqnet.telemednet;
|
package com.cosqnet.medoraprovider;
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity;
|
import io.flutter.embedding.android.FlutterActivity;
|
||||||
|
|
||||||
@ -368,7 +368,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -384,7 +384,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@ -401,7 +401,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@ -416,7 +416,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@ -547,7 +547,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@ -569,7 +569,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
96
lib/controllers/patient_controller.dart
Normal file
96
lib/controllers/patient_controller.dart
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import 'package:medora/data/models/patient.dart';
|
||||||
|
import '../data/services/patient_registration_service.dart';
|
||||||
|
|
||||||
|
class PatientController {
|
||||||
|
final PatientModel model = PatientModel();
|
||||||
|
Map<String, String> validationErrors = {};
|
||||||
|
|
||||||
|
void clearValidationErrors() {
|
||||||
|
validationErrors.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateName(String name) {
|
||||||
|
model.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePhoneNumber(String phoneNumber) {
|
||||||
|
model.phoneNumber = phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateGender(String gender) {
|
||||||
|
model.gender = gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateDateOfBirth(DateTime dateOfBirth) {
|
||||||
|
model.dateOfBirth = dateOfBirth;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateProfileImage(String imagePath) {
|
||||||
|
model.profileImagePath = imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateHouseNo(String houseNo) {
|
||||||
|
model.address.houseNo = houseNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateLine(String line) {
|
||||||
|
model.address.line = line;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTown(String town) {
|
||||||
|
model.address.town = town;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePincode(String pincode) {
|
||||||
|
model.address.pincode = pincode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateCountry(String country) {
|
||||||
|
model.address.country = country;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateState(String state) {
|
||||||
|
model.address.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateCity(String city) {
|
||||||
|
model.address.city = city;
|
||||||
|
}
|
||||||
|
|
||||||
|
void addFamilyMember(FamilyMember member) {
|
||||||
|
model.familyMembers.add(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFamilyMember(int index, FamilyMember member) {
|
||||||
|
if (index >= 0 && index < model.familyMembers.length) {
|
||||||
|
model.familyMembers[index] = member;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteFamilyMember(int index) {
|
||||||
|
if (index >= 0 && index < model.familyMembers.length) {
|
||||||
|
model.familyMembers.removeAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> savePatientData() async {
|
||||||
|
return await PatientProfileService.savePatientProfile(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> loadPatientData() async {
|
||||||
|
PatientModel? loadedModel = await PatientProfileService.getPatientProfile();
|
||||||
|
if (loadedModel != null) {
|
||||||
|
model.updateFrom(loadedModel);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> updatePatientData() async {
|
||||||
|
return await PatientProfileService.updatePatientProfile(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> deletePatientData() async {
|
||||||
|
return await PatientProfileService.deletePatientProfile();
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/data/models/patient.dart
Normal file
126
lib/data/models/patient.dart
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
class PatientModel {
|
||||||
|
String? name;
|
||||||
|
String? phoneNumber;
|
||||||
|
String? gender;
|
||||||
|
DateTime? dateOfBirth;
|
||||||
|
String? profileImagePath;
|
||||||
|
String? profileImageUrl;
|
||||||
|
PatientAddress address;
|
||||||
|
|
||||||
|
List<FamilyMember> familyMembers = [];
|
||||||
|
|
||||||
|
PatientModel() : address = PatientAddress();
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'phoneNumber': phoneNumber,
|
||||||
|
'gender': gender,
|
||||||
|
'dateOfBirth': dateOfBirth?.toIso8601String(),
|
||||||
|
'profileImagePath': profileImagePath,
|
||||||
|
'profileImageUrl': profileImageUrl,
|
||||||
|
'address': address.toJson(),
|
||||||
|
'familyMembers': familyMembers.map((member) => member.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PatientModel.fromJson(Map<String, dynamic> json)
|
||||||
|
: address = PatientAddress() {
|
||||||
|
name = json['name'];
|
||||||
|
phoneNumber = json['phoneNumber'];
|
||||||
|
gender = json['gender'];
|
||||||
|
dateOfBirth = json['dateOfBirth'] != null
|
||||||
|
? DateTime.parse(json['dateOfBirth'])
|
||||||
|
: null;
|
||||||
|
profileImagePath = json['profileImagePath'];
|
||||||
|
profileImageUrl = json['profileImageUrl'];
|
||||||
|
if (json['address'] != null) {
|
||||||
|
address =
|
||||||
|
PatientAddress.fromJson(json['address'] as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
if (json['familyMembers'] != null) {
|
||||||
|
familyMembers = (json['familyMembers'] as List)
|
||||||
|
.map((memberJson) => FamilyMember.fromJson(memberJson))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFrom(PatientModel other) {
|
||||||
|
name = other.name;
|
||||||
|
phoneNumber = other.phoneNumber;
|
||||||
|
gender = other.gender;
|
||||||
|
dateOfBirth = other.dateOfBirth;
|
||||||
|
profileImagePath = other.profileImagePath;
|
||||||
|
profileImageUrl = other.profileImageUrl;
|
||||||
|
address = other.address;
|
||||||
|
familyMembers = other.familyMembers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FamilyMember {
|
||||||
|
String? name;
|
||||||
|
String? relation;
|
||||||
|
String? gender;
|
||||||
|
DateTime? dateOfBirth;
|
||||||
|
|
||||||
|
FamilyMember({this.name, this.relation, this.gender, this.dateOfBirth});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'relation': relation,
|
||||||
|
'gender': gender,
|
||||||
|
'dateOfBirth': dateOfBirth?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
FamilyMember.fromJson(Map<String, dynamic> json) {
|
||||||
|
name = json['name'];
|
||||||
|
relation = json['relation'];
|
||||||
|
gender = json['gender'];
|
||||||
|
dateOfBirth = json['dateOfBirth'] != null
|
||||||
|
? DateTime.parse(json['dateOfBirth'])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PatientAddress {
|
||||||
|
String? houseNo;
|
||||||
|
String? line;
|
||||||
|
String? town;
|
||||||
|
String? pincode;
|
||||||
|
String? country;
|
||||||
|
String? state;
|
||||||
|
String? city;
|
||||||
|
|
||||||
|
PatientAddress(
|
||||||
|
{this.houseNo,
|
||||||
|
this.line,
|
||||||
|
this.town,
|
||||||
|
this.pincode,
|
||||||
|
this.country,
|
||||||
|
this.state,
|
||||||
|
this.city});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'houseNo': houseNo,
|
||||||
|
'line': line,
|
||||||
|
'town': town,
|
||||||
|
'pincode': pincode,
|
||||||
|
'country': country,
|
||||||
|
'state': state,
|
||||||
|
'city': city,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PatientAddress.fromJson(Map<String, dynamic> json) {
|
||||||
|
houseNo = json['houseNo'];
|
||||||
|
line = json['line'];
|
||||||
|
town = json['town'];
|
||||||
|
pincode = json['pincode'];
|
||||||
|
country = json['country'];
|
||||||
|
state = json['state'];
|
||||||
|
city = json['city'];
|
||||||
|
}
|
||||||
|
}
|
||||||
198
lib/data/services/patient_registration_service.dart
Normal file
198
lib/data/services/patient_registration_service.dart
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:medora/controllers/patient_controller.dart';
|
||||||
|
import 'package:medora/data/models/patient.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
class PatientProfileService {
|
||||||
|
static final String patientProfileCollectionName =
|
||||||
|
dotenv.env['PATIENT_PROFILE_COLLECTION_NAME']!;
|
||||||
|
static final FirebaseFirestore db = FirebaseFirestore.instance;
|
||||||
|
static final FirebaseStorage storage = FirebaseStorage.instanceFor(
|
||||||
|
bucket: dotenv.env['FIREBASE_STORAGE_BUCKET']!);
|
||||||
|
|
||||||
|
static Future<String?> uploadProfileImage(File imageFile) async {
|
||||||
|
try {
|
||||||
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
print('No user logged in');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String uid = user.uid;
|
||||||
|
final String fileName =
|
||||||
|
'profile_${uid}_${DateTime.now().millisecondsSinceEpoch}${path.extension(imageFile.path)}';
|
||||||
|
final Reference storageRef =
|
||||||
|
storage.ref().child('profile_images/$fileName');
|
||||||
|
final UploadTask uploadTask = storageRef.putFile(
|
||||||
|
imageFile,
|
||||||
|
SettableMetadata(
|
||||||
|
contentType: 'image/${path.extension(imageFile.path).substring(1)}',
|
||||||
|
customMetadata: {
|
||||||
|
'userId': uid,
|
||||||
|
'uploadedAt': DateTime.now().toIso8601String(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final TaskSnapshot snapshot = await uploadTask;
|
||||||
|
final String downloadUrl = await snapshot.ref.getDownloadURL();
|
||||||
|
|
||||||
|
print('Profile image uploaded successfully');
|
||||||
|
return downloadUrl;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error uploading profile image: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> deleteProfileImage(String imageUrl) async {
|
||||||
|
try {
|
||||||
|
final Reference storageRef = storage.refFromURL(imageUrl);
|
||||||
|
await storageRef.delete();
|
||||||
|
print('Profile image deleted successfully');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting profile image: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> savePatientProfile(PatientController controller) async {
|
||||||
|
try {
|
||||||
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
print('No user logged in');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String uid = user.uid;
|
||||||
|
final PatientModel patientData = controller.model;
|
||||||
|
String? imageUrl;
|
||||||
|
if (patientData.profileImagePath != null) {
|
||||||
|
final File imageFile = File(patientData.profileImagePath!);
|
||||||
|
imageUrl = await uploadProfileImage(imageFile);
|
||||||
|
if (imageUrl == null) {
|
||||||
|
print('Failed to upload profile image');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> patientJson = patientData.toJson();
|
||||||
|
patientJson['createdAt'] = FieldValue.serverTimestamp();
|
||||||
|
patientJson['updatedAt'] = FieldValue.serverTimestamp();
|
||||||
|
patientJson['uid'] = uid;
|
||||||
|
patientJson['profileImageUrl'] = imageUrl;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.collection(patientProfileCollectionName)
|
||||||
|
.doc(uid)
|
||||||
|
.set(patientJson);
|
||||||
|
|
||||||
|
print('Patient profile saved successfully');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error saving patient profile: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> updatePatientProfile(PatientModel patient) async {
|
||||||
|
try {
|
||||||
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
print('No user logged in');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String uid = user.uid;
|
||||||
|
String? imageUrl;
|
||||||
|
if (patient.profileImagePath != null) {
|
||||||
|
final DocumentSnapshot oldDoc =
|
||||||
|
await db.collection(patientProfileCollectionName).doc(uid).get();
|
||||||
|
if (oldDoc.exists) {
|
||||||
|
final oldData = oldDoc.data() as Map<String, dynamic>;
|
||||||
|
final String? oldImageUrl = oldData['profileImageUrl'];
|
||||||
|
if (oldImageUrl != null) {
|
||||||
|
await deleteProfileImage(oldImageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final File imageFile = File(patient.profileImagePath!);
|
||||||
|
imageUrl = await uploadProfileImage(imageFile);
|
||||||
|
if (imageUrl == null) {
|
||||||
|
print('Failed to upload new profile image');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> patientJson = patient.toJson();
|
||||||
|
patientJson['updatedAt'] = FieldValue.serverTimestamp();
|
||||||
|
if (imageUrl != null) {
|
||||||
|
patientJson['profileImageUrl'] = imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.collection(patientProfileCollectionName)
|
||||||
|
.doc(uid)
|
||||||
|
.update(patientJson);
|
||||||
|
|
||||||
|
print('Patient profile updated successfully');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error updating patient profile: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> deletePatientProfile() async {
|
||||||
|
try {
|
||||||
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
print('No user logged in');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String uid = user.uid;
|
||||||
|
final DocumentSnapshot doc =
|
||||||
|
await db.collection(patientProfileCollectionName).doc(uid).get();
|
||||||
|
if (doc.exists) {
|
||||||
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
final String? imageUrl = data['profileImageUrl'];
|
||||||
|
if (imageUrl != null) {
|
||||||
|
await deleteProfileImage(imageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection(patientProfileCollectionName).doc(uid).delete();
|
||||||
|
|
||||||
|
print('Patient profile deleted successfully');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error deleting patient profile: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<PatientModel?> getPatientProfile() async {
|
||||||
|
try {
|
||||||
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user == null) {
|
||||||
|
print('No user logged in');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String uid = user.uid;
|
||||||
|
final DocumentSnapshot doc =
|
||||||
|
await db.collection(patientProfileCollectionName).doc(uid).get();
|
||||||
|
if (!doc.exists) {
|
||||||
|
print('No patient profile found for this user');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
return PatientModel.fromJson(data);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error fetching patient profile: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -85,5 +85,4 @@ class DefaultFirebaseOptions {
|
|||||||
storageBucket: 'cosq-telemednet-dev.appspot.com',
|
storageBucket: 'cosq-telemednet-dev.appspot.com',
|
||||||
measurementId: 'G-BBV9TFGNN5',
|
measurementId: 'G-BBV9TFGNN5',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,842 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:medora/data/models/consultation_center.dart';
|
||||||
|
import 'package:medora/data/models/doctor.dart';
|
||||||
|
import 'package:medora/data/models/patient.dart';
|
||||||
|
import 'package:medora/data/services/consultation_booking_service.dart';
|
||||||
|
import 'package:medora/data/services/patient_registration_service.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
import 'package:medora/widgets/alert_screen.dart';
|
||||||
|
|
||||||
|
class ConsultationBookingScreen extends StatefulWidget {
|
||||||
|
final Doctor doctor;
|
||||||
|
final ConsultationCenter selectedConsultation;
|
||||||
|
final DateTime selectedDate;
|
||||||
|
final String selectedTime;
|
||||||
|
|
||||||
|
const ConsultationBookingScreen({
|
||||||
|
super.key,
|
||||||
|
required this.doctor,
|
||||||
|
required this.selectedConsultation,
|
||||||
|
required this.selectedDate,
|
||||||
|
required this.selectedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConsultationBookingScreen> createState() =>
|
||||||
|
_ConsultationBookingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConsultationBookingScreenState extends State<ConsultationBookingScreen> {
|
||||||
|
PatientModel? selectedPatient;
|
||||||
|
List<PatientModel> familyMembers = [];
|
||||||
|
FamilyMember? selectedFamilyMember;
|
||||||
|
bool isLoading = true;
|
||||||
|
final TextEditingController _nameController = TextEditingController();
|
||||||
|
final TextEditingController _relationController = TextEditingController();
|
||||||
|
DateTime? _selectedDateOfBirth;
|
||||||
|
String _selectedGender = 'Male';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_relationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPatientProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPatientProfile() async {
|
||||||
|
setState(() => isLoading = true);
|
||||||
|
try {
|
||||||
|
final currentPatient = await PatientProfileService.getPatientProfile();
|
||||||
|
if (currentPatient != null) {
|
||||||
|
setState(() {
|
||||||
|
selectedPatient = currentPatient;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading patient data: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() => isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedAddress {
|
||||||
|
final parts = [
|
||||||
|
widget.selectedConsultation.floorBuilding,
|
||||||
|
widget.selectedConsultation.street,
|
||||||
|
widget.selectedConsultation.city,
|
||||||
|
widget.selectedConsultation.state,
|
||||||
|
widget.selectedConsultation.postalCode
|
||||||
|
].where((part) => part != null && part.isNotEmpty).toList();
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F7FF),
|
||||||
|
appBar: _buildAppBar(context),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildAppointmentCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildDoctorDetails(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildLocationDetails(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildPaymentDetails(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildInClinicAppointmentText(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildConfirmButton(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInClinicAppointmentText() {
|
||||||
|
String patientName =
|
||||||
|
selectedFamilyMember?.name ?? selectedPatient?.name ?? 'Select Patient';
|
||||||
|
String relation = selectedFamilyMember?.relation != null
|
||||||
|
? ' (${selectedFamilyMember!.relation})'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const TextSpan(text: 'In-clinic appointment for '),
|
||||||
|
TextSpan(
|
||||||
|
text: '$patientName$relation',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _showPatientSelectionDialog,
|
||||||
|
child: Text(
|
||||||
|
'Change',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar(BuildContext context) {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Booking Overview',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppointmentCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blue.withOpacity(0.3),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
DateFormat('EEEE, MMMM d').format(widget.selectedDate),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.access_time, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
widget.selectedTime,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoctorDetails() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
widget.doctor.profileImageUrl!,
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: Icon(Icons.person, size: 40, color: Colors.grey[600]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.doctor.firstName ?? '',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.doctor.speciality!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${widget.doctor.yearsOfExperience} years experience',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocationDetails() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Location',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
formattedAddress,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Average consultation time: ${widget.selectedConsultation.averageDurationMinutes} minutes',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPaymentDetails() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Payment Details',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Consultation Fee',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'₹${widget.selectedConsultation.consultationFee ?? "500"}',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConfirmButton(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Handle payment and booking confirmation
|
||||||
|
_showConfirmationDialog(context);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Confirm & Pay',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showConfirmationDialog(BuildContext context) async {
|
||||||
|
if (selectedPatient == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Please select a patient for the appointment'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bookingService = BookingService();
|
||||||
|
final currentUser = FirebaseAuth.instance.currentUser;
|
||||||
|
|
||||||
|
// Get the correct patient name based on selection
|
||||||
|
final patientName = selectedFamilyMember != null
|
||||||
|
? selectedFamilyMember!.name
|
||||||
|
: selectedPatient!.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (context.mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bookingId = await bookingService.createBooking(
|
||||||
|
doctorId: widget.doctor.uid!,
|
||||||
|
profileImageUrl: widget.doctor.profileImageUrl!,
|
||||||
|
doctorName: widget.doctor.firstName ?? 'Doctor',
|
||||||
|
patientId: currentUser!.uid,
|
||||||
|
patientName: patientName ?? 'Patient',
|
||||||
|
location: formattedAddress,
|
||||||
|
appointmentDate: widget.selectedDate,
|
||||||
|
appointmentTime: widget.selectedTime,
|
||||||
|
consultationFee:
|
||||||
|
int.parse(widget.selectedConsultation.consultationFee ?? "500"),
|
||||||
|
specialization: widget.doctor.speciality!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => AlertScreen(
|
||||||
|
arguments: AlertArguments(
|
||||||
|
title: 'Booking Confirmed',
|
||||||
|
message:
|
||||||
|
'Your in-clinic appointment has been successfully booked for $patientName. Booking ID: ${bookingId.substring(0, 8)}\n\nPlease complete the payment to confirm your appointment.',
|
||||||
|
actionTitle: 'View Appointments',
|
||||||
|
type: AlertType.success,
|
||||||
|
onActionPressed: () {
|
||||||
|
Navigator.pushReplacementNamed(
|
||||||
|
context, RouteNames.patientDashboardScreen);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => AlertScreen(
|
||||||
|
arguments: AlertArguments(
|
||||||
|
title: 'Booking Failed',
|
||||||
|
message: 'Unable to create booking. ${e.toString()}',
|
||||||
|
actionTitle: 'Try Again',
|
||||||
|
type: AlertType.error,
|
||||||
|
onActionPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddFamilyMemberDialog() async {
|
||||||
|
_nameController.clear();
|
||||||
|
_relationController.clear();
|
||||||
|
setState(() {
|
||||||
|
_selectedDateOfBirth = null;
|
||||||
|
_selectedGender = 'Male';
|
||||||
|
});
|
||||||
|
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext dialogContext) => StatefulBuilder(
|
||||||
|
builder: (BuildContext context, StateSetter setDialogState) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape:
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
title: Text(
|
||||||
|
'Add Family Member',
|
||||||
|
style: GoogleFonts.poppins(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
content: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Full Name',
|
||||||
|
labelStyle: GoogleFonts.poppins(),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _relationController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Relation',
|
||||||
|
labelStyle: GoogleFonts.poppins(),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(
|
||||||
|
Icons.family_restroom,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: DateTime.now(),
|
||||||
|
firstDate: DateTime(1900),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: Colors.blue,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
surface: Colors.grey[100]!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setDialogState(() {
|
||||||
|
_selectedDateOfBirth = picked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey[300]!),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today,
|
||||||
|
color: Colors.blue),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_selectedDateOfBirth != null
|
||||||
|
? DateFormat('dd/MM/yyyy')
|
||||||
|
.format(_selectedDateOfBirth!)
|
||||||
|
: 'Select Date of Birth',
|
||||||
|
style: GoogleFonts.poppins(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey[300]!),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: _selectedGender,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.person_outline,
|
||||||
|
color: Colors.blue),
|
||||||
|
border: InputBorder.none,
|
||||||
|
labelStyle: GoogleFonts.poppins(),
|
||||||
|
),
|
||||||
|
items: ['Male', 'Female', 'Other']
|
||||||
|
.map((gender) => DropdownMenuItem(
|
||||||
|
value: gender,
|
||||||
|
child: Text(gender,
|
||||||
|
style: GoogleFonts.poppins()),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setDialogState(() => _selectedGender = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
'Cancel',
|
||||||
|
style: GoogleFonts.poppins(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _addFamilyMember(context),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Add Member',
|
||||||
|
style: GoogleFonts.poppins(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addFamilyMember(BuildContext context) async {
|
||||||
|
if (_nameController.text.isEmpty ||
|
||||||
|
_relationController.text.isEmpty ||
|
||||||
|
_selectedDateOfBirth == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Please fill in all fields',
|
||||||
|
style: GoogleFonts.poppins(),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newFamilyMember = FamilyMember(
|
||||||
|
name: _nameController.text,
|
||||||
|
relation: _relationController.text,
|
||||||
|
gender: _selectedGender,
|
||||||
|
dateOfBirth: _selectedDateOfBirth,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedPatient != null) {
|
||||||
|
selectedPatient!.familyMembers.add(newFamilyMember);
|
||||||
|
await PatientProfileService.updatePatientProfile(selectedPatient!);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
selectedFamilyMember = newFamilyMember;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
_showPatientSelectionDialog();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context); // Pop add family member dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPatientSelectionDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
title: Text(
|
||||||
|
'Select Patient',
|
||||||
|
style: GoogleFonts.poppins(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
// Main patient
|
||||||
|
_buildPatientTile(
|
||||||
|
name: selectedPatient?.name ?? '',
|
||||||
|
subtitle: 'Primary Patient',
|
||||||
|
isSelected: selectedFamilyMember == null,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
selectedFamilyMember = null;
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// Family members
|
||||||
|
...selectedPatient?.familyMembers.map(
|
||||||
|
(member) => _buildPatientTile(
|
||||||
|
name: member.name ?? '',
|
||||||
|
subtitle: member.relation ?? '',
|
||||||
|
isSelected: selectedFamilyMember == member,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
selectedFamilyMember = member;
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
[],
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.person_add, color: Colors.blue),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Add Family Member',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showAddFamilyMemberDialog();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPatientTile({
|
||||||
|
required String name,
|
||||||
|
required String subtitle,
|
||||||
|
required bool isSelected,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.blue.withOpacity(0.1)
|
||||||
|
: Colors.grey.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: isSelected ? Colors.blue : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
name,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? Colors.blue : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? const Icon(Icons.check_circle, color: Colors.blue)
|
||||||
|
: null,
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,553 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:medora/data/models/consultation_center.dart';
|
||||||
|
import 'package:medora/data/models/doctor.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
|
||||||
|
class ConsultationTimeScreen extends StatefulWidget {
|
||||||
|
final Doctor doctor;
|
||||||
|
final ConsultationCenter selectedConsultation;
|
||||||
|
|
||||||
|
const ConsultationTimeScreen({
|
||||||
|
super.key,
|
||||||
|
required this.doctor,
|
||||||
|
required this.selectedConsultation,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConsultationTimeScreen> createState() => _ConsultationTimeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConsultationTimeScreenState extends State<ConsultationTimeScreen> {
|
||||||
|
DateTime? selectedDate;
|
||||||
|
String? selectedTime;
|
||||||
|
|
||||||
|
List<TimeSlot> getTimeSlotsForDay(String dayName) {
|
||||||
|
try {
|
||||||
|
final schedule = widget.selectedConsultation.weeklySchedule?.firstWhere(
|
||||||
|
(schedule) => schedule.day == dayName,
|
||||||
|
orElse: () => AvailabilitySchedule(
|
||||||
|
day: dayName,
|
||||||
|
timeSlots: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return schedule?.timeSlots ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error getting time slots: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? parseTimeString(String? timeStr) {
|
||||||
|
if (timeStr == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try parsing 12-hour format first
|
||||||
|
return DateFormat('h:mm a').parse(timeStr);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
// Try parsing 24-hour format
|
||||||
|
return DateFormat('HH:mm').parse(timeStr);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error parsing time: $timeStr');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get formattedAddress {
|
||||||
|
final parts = [
|
||||||
|
widget.selectedConsultation.floorBuilding,
|
||||||
|
widget.selectedConsultation.street,
|
||||||
|
widget.selectedConsultation.city,
|
||||||
|
widget.selectedConsultation.state,
|
||||||
|
widget.selectedConsultation.postalCode
|
||||||
|
].where((part) => part != null && part.isNotEmpty).toList();
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F7FF),
|
||||||
|
appBar: _buildAppBar(),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildLocationInfo(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildDateSelection(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildTimeSlots(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar() {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Select Date & Time',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocationInfo() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.doctor.firstName ?? '',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.doctor.speciality!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
widget.doctor.profileImageUrl!,
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child:
|
||||||
|
Icon(Icons.person, size: 30, color: Colors.grey[600]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
Text(
|
||||||
|
'Selected Location',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.selectedConsultation.city ?? '',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Average consultation time: ${widget.selectedConsultation.averageDurationMinutes}',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDateSelection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Select Date',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 100,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: 20, // Show next 20 days
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final date = DateTime.now().add(Duration(days: index));
|
||||||
|
final isSelected = selectedDate?.day == date.day &&
|
||||||
|
selectedDate?.month == date.month &&
|
||||||
|
selectedDate?.year == date.year;
|
||||||
|
final isAvailable = _isDateAvailable(date);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: isAvailable
|
||||||
|
? () {
|
||||||
|
setState(() {
|
||||||
|
selectedDate = date;
|
||||||
|
selectedTime = null; // Reset time when date changes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Container(
|
||||||
|
width: 70,
|
||||||
|
margin: const EdgeInsets.only(right: 12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.blue
|
||||||
|
: isAvailable
|
||||||
|
? Colors.white
|
||||||
|
: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: isAvailable
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat('EEE').format(date).toUpperCase(),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: isAvailable
|
||||||
|
? Colors.grey[600]
|
||||||
|
: Colors.grey[400],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
date.day.toString(),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: isAvailable
|
||||||
|
? Colors.black87
|
||||||
|
: Colors.grey[400],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimeSlots() {
|
||||||
|
if (selectedDate == null) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Please select a date to view available time slots',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dayName = DateFormat('EEEE').format(selectedDate!);
|
||||||
|
final timeSlots = getTimeSlotsForDay(dayName);
|
||||||
|
final allTimeSlots = _generateTimeSlots(timeSlots);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Select Time',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: allTimeSlots.map((time) {
|
||||||
|
final isSelected = selectedTime == time;
|
||||||
|
final isAvailable = _isTimeSlotAvailable(time);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: isAvailable
|
||||||
|
? () {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
RouteNames.consultationBookingScreen,
|
||||||
|
arguments: {
|
||||||
|
'doctor': widget.doctor,
|
||||||
|
'selectedConsultation': widget.selectedConsultation,
|
||||||
|
'selectedDate': selectedDate,
|
||||||
|
'selectedTime': time
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.blue
|
||||||
|
: isAvailable
|
||||||
|
? Colors.white
|
||||||
|
: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.blue
|
||||||
|
: isAvailable
|
||||||
|
? Colors.grey.withOpacity(0.2)
|
||||||
|
: Colors.grey.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
time,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: isAvailable
|
||||||
|
? Colors.black87
|
||||||
|
: Colors.grey[400],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isDateAvailable(DateTime date) {
|
||||||
|
final dayName = DateFormat('EEEE').format(date);
|
||||||
|
return widget.selectedConsultation.weeklySchedule
|
||||||
|
?.any((schedule) => schedule.day == dayName) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _generateTimeSlots(List<TimeSlot> timeSlots) {
|
||||||
|
final slots = <String>[];
|
||||||
|
final timeFormat = DateFormat('h:mm a');
|
||||||
|
|
||||||
|
for (var slot in timeSlots) {
|
||||||
|
final startTime = parseTimeString(slot.startTime);
|
||||||
|
final endTime = parseTimeString(slot.endTime);
|
||||||
|
|
||||||
|
if (startTime == null || endTime == null) continue;
|
||||||
|
|
||||||
|
var currentTime = startTime;
|
||||||
|
while (currentTime.isBefore(endTime)) {
|
||||||
|
slots.add(timeFormat.format(currentTime));
|
||||||
|
currentTime = currentTime.add(const Duration(minutes: 30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTimeSlotAvailable(String time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
if (selectedDate == null) return false;
|
||||||
|
|
||||||
|
// Parse the time slot
|
||||||
|
final timeSlot = parseTimeString(time);
|
||||||
|
if (timeSlot == null) return false;
|
||||||
|
|
||||||
|
// Create a DateTime combining selected date and time
|
||||||
|
final slotDateTime = DateTime(
|
||||||
|
selectedDate!.year,
|
||||||
|
selectedDate!.month,
|
||||||
|
selectedDate!.day,
|
||||||
|
timeSlot.hour,
|
||||||
|
timeSlot.minute,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the slot is in the past
|
||||||
|
if (slotDateTime.isBefore(now)) return false;
|
||||||
|
|
||||||
|
// Here you would typically check against your booking database
|
||||||
|
// For now, returning true for future slots
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBooking() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Confirm Booking',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildConfirmationDetail(
|
||||||
|
'Doctor',
|
||||||
|
widget.doctor.firstName!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildConfirmationDetail(
|
||||||
|
'Location',
|
||||||
|
widget.selectedConsultation.city!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildConfirmationDetail(
|
||||||
|
'Date',
|
||||||
|
DateFormat('EEEE, MMMM d').format(selectedDate!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildConfirmationDetail(
|
||||||
|
'Time',
|
||||||
|
selectedTime!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
'Cancel',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Confirm',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConfirmationDetail(String label, String value) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,312 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:medora/data/models/doctor.dart';
|
||||||
|
import 'package:medora/data/models/consultation_center.dart';
|
||||||
|
import 'package:medora/data/services/consultation_center_service.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
|
||||||
|
class ConsultationsCenterScreen extends StatefulWidget {
|
||||||
|
final Doctor doctor;
|
||||||
|
|
||||||
|
const ConsultationsCenterScreen({
|
||||||
|
super.key,
|
||||||
|
required this.doctor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConsultationsCenterScreen> createState() =>
|
||||||
|
_ConsultationsCenterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConsultationsCenterScreenState extends State<ConsultationsCenterScreen> {
|
||||||
|
List<ConsultationCenter> _consultationCenters = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchDoctorConsultationCenters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchDoctorConsultationCenters() async {
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (widget.doctor.uid == null) {
|
||||||
|
throw Exception('Doctor UID is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
final centers =
|
||||||
|
await ConsultationCenterService.getDoctorConsultationCenters(
|
||||||
|
widget.doctor.uid!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_consultationCenters = centers;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to load consultation centers: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAddress(ConsultationCenter center) {
|
||||||
|
List<String> addressParts = [];
|
||||||
|
|
||||||
|
if (center.floorBuilding != null && center.floorBuilding!.isNotEmpty) {
|
||||||
|
addressParts.add(center.floorBuilding!);
|
||||||
|
}
|
||||||
|
if (center.street != null && center.street!.isNotEmpty) {
|
||||||
|
addressParts.add(center.street!);
|
||||||
|
}
|
||||||
|
if (center.city != null && center.city!.isNotEmpty) {
|
||||||
|
addressParts.add(center.city!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addressParts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F7FF),
|
||||||
|
appBar: _buildAppBar(),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _fetchDoctorConsultationCenters,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDoctorInfo(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildConsultationLocations(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar() {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Select Location',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoctorInfo() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
widget.doctor.profileImageUrl!,
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
color: Colors.grey[300],
|
||||||
|
child: Icon(Icons.person, size: 40, color: Colors.grey[600]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.doctor.firstName ?? "",
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.doctor.speciality!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${widget.doctor.yearsOfExperience} years experience',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConsultationLocations() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Select Location',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else if (_error != null)
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Error loading centers',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _fetchDoctorConsultationCenters,
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_consultationCenters.isEmpty)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'No consultation centers available',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _consultationCenters.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final center = _consultationCenters[index];
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
RouteNames.consultationTimeScreen,
|
||||||
|
arguments: {
|
||||||
|
'doctor': widget.doctor,
|
||||||
|
'selectedConsultation': center,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
margin: const EdgeInsets.only(right: 16),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatAddress(center),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (center.averageDurationMinutes != null)
|
||||||
|
Text(
|
||||||
|
'Average time: ${center.averageDurationMinutes} mins',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (center.consultationFee != null)
|
||||||
|
Text(
|
||||||
|
'Fee: ${center.consultationFee}',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,346 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:medora/data/models/doctor.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
|
class DoctorDetailsScreen extends StatefulWidget {
|
||||||
|
final Doctor doctor;
|
||||||
|
final ImageProvider? preloadedImage;
|
||||||
|
|
||||||
|
const DoctorDetailsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.doctor,
|
||||||
|
this.preloadedImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DoctorDetailsScreen> createState() => _DoctorDetailsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorDetailsScreenState extends State<DoctorDetailsScreen> {
|
||||||
|
bool isDescriptionExpanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F7FF),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
_buildAppBar(context),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDoctorCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildDescription(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildQualifications(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context, RouteNames.consultationCenterScreen,
|
||||||
|
arguments: {
|
||||||
|
'doctor': widget.doctor,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
disabledBackgroundColor: Colors.grey[300],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Confirm Booking',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.white,
|
||||||
|
child: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Doctor',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoctorCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDoctorImage(),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.doctor.firstName ?? '',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.doctor.speciality!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.medical_services,
|
||||||
|
size: 16, color: Colors.blue[400]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.doctor.speciality!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.location_on,
|
||||||
|
size: 16, color: Colors.blue[400]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.doctor.city!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.star, size: 16, color: Colors.blue[400]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${widget.doctor.yearsOfExperience} Years',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoctorImage() {
|
||||||
|
final imageProvider =
|
||||||
|
widget.preloadedImage ?? NetworkImage(widget.doctor.profileImageUrl!);
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image(
|
||||||
|
image: imageProvider,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||||
|
if (wasSynchronouslyLoaded || frame != null) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Shimmer.fromColors(
|
||||||
|
baseColor: Colors.grey[300]!,
|
||||||
|
highlightColor: Colors.grey[100]!,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 50,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDescription() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Description',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.doctor.profileDescription!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
maxLines: isDescriptionExpanded ? null : 3,
|
||||||
|
overflow: isDescriptionExpanded ? null : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
isDescriptionExpanded = !isDescriptionExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isDescriptionExpanded ? 'Show less' : 'Read more',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQualifications() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Qualifications',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.doctor.qualifications?.join(', ') ?? '',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,387 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:medora/data/models/doctor.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
|
class DoctorsListScreen extends StatefulWidget {
|
||||||
|
final String specialty;
|
||||||
|
|
||||||
|
const DoctorsListScreen({
|
||||||
|
super.key,
|
||||||
|
required this.specialty,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DoctorsListScreen> createState() => _DoctorsListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorsListScreenState extends State<DoctorsListScreen> {
|
||||||
|
late final Query doctorsQuery;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
bool _isSearching = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
doctorsQuery = FirebaseFirestore.instance
|
||||||
|
.collection('doctorprofiles')
|
||||||
|
.where('speciality', isEqualTo: widget.specialty);
|
||||||
|
_searchController.addListener(_onSearchChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
|
setState(() {
|
||||||
|
_isSearching = _searchController.text.isNotEmpty;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F7FF),
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildSliverAppBar(),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildSearchBar(),
|
||||||
|
),
|
||||||
|
_buildDoctorsList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSliverAppBar() {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 55,
|
||||||
|
floating: true,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
title: Text(
|
||||||
|
'${widget.specialty} Specialists',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchBar() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search doctors...',
|
||||||
|
hintStyle: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(Icons.search, color: Colors.grey),
|
||||||
|
suffixIcon: _isSearching
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoctorsList() {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: StreamBuilder<QuerySnapshot>(
|
||||||
|
stream: doctorsQuery.snapshots(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildShimmerDoctorCard(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
|
||||||
|
return SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.medical_services_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No doctors available in this specialty',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final doctors = snapshot.data!.docs
|
||||||
|
.map((doc) => Doctor.fromJson(doc.data() as Map<String, dynamic>))
|
||||||
|
.where((doctor) {
|
||||||
|
if (_searchController.text.isEmpty) return true;
|
||||||
|
final searchQuery = _searchController.text.toLowerCase();
|
||||||
|
return doctor.firstName!.toLowerCase().contains(searchQuery) ||
|
||||||
|
doctor.city!.toLowerCase().contains(searchQuery);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final doctor = doctors[index];
|
||||||
|
return _buildDoctorCard(doctor);
|
||||||
|
},
|
||||||
|
childCount: doctors.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildShimmerDoctorCard() {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Shimmer.fromColors(
|
||||||
|
baseColor: Colors.grey[300]!,
|
||||||
|
highlightColor: Colors.grey[100]!,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 150,
|
||||||
|
height: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 100,
|
||||||
|
height: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoctorCard(Doctor doctor) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
precacheImage(NetworkImage(doctor.profileImageUrl!), context);
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
RouteNames.doctorDetailsScreen,
|
||||||
|
arguments: {
|
||||||
|
'doctor': doctor,
|
||||||
|
'imageProvider': NetworkImage(doctor.profileImageUrl!),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildDoctorImage(doctor),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
doctor.firstName ?? '',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${doctor.yearsOfExperience!} years experience',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
doctor.city!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDoctorImage(Doctor doctor) {
|
||||||
|
return Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
doctor.profileImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
|
if (loadingProgress == null) return child;
|
||||||
|
return Shimmer.fromColors(
|
||||||
|
baseColor: Colors.grey[300]!,
|
||||||
|
highlightColor: Colors.grey[100]!,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,426 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:animate_do/animate_do.dart';
|
||||||
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
|
||||||
|
class Specialty {
|
||||||
|
final String name;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
Specialty({
|
||||||
|
required this.name,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpecialtyScreen extends StatefulWidget {
|
||||||
|
const SpecialtyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpecialtyScreen> createState() => _SpecialtyScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpecialtyScreenState extends State<SpecialtyScreen> {
|
||||||
|
final List<Specialty> _allSpecialties = [
|
||||||
|
Specialty(
|
||||||
|
name: 'Pediatric',
|
||||||
|
icon: Icons.child_care,
|
||||||
|
color: Colors.blue,
|
||||||
|
description: 'Medical care for infants, children, and adolescents',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'General Medicine',
|
||||||
|
icon: Icons.medical_services,
|
||||||
|
color: Colors.green,
|
||||||
|
description:
|
||||||
|
'Primary healthcare for adults and general medical conditions',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Family Medicine',
|
||||||
|
icon: Icons.family_restroom,
|
||||||
|
color: Colors.teal,
|
||||||
|
description: 'Comprehensive healthcare for families and individuals',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Cardiologist',
|
||||||
|
icon: Icons.favorite,
|
||||||
|
color: Colors.red,
|
||||||
|
description: 'Diagnosis and treatment of heart conditions',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Neurology',
|
||||||
|
icon: Icons.psychology,
|
||||||
|
color: Colors.purple,
|
||||||
|
description: 'Treatment of nervous system disorders',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Gastroenterology',
|
||||||
|
icon: Icons.local_hospital,
|
||||||
|
color: Colors.orange,
|
||||||
|
description: 'Digestive system disorders and treatment',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Dermatologist',
|
||||||
|
icon: Icons.face,
|
||||||
|
color: Colors.pink,
|
||||||
|
description: 'Skin, hair, and nail conditions',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Orthopedic',
|
||||||
|
icon: Icons.wheelchair_pickup,
|
||||||
|
color: Colors.indigo,
|
||||||
|
description: 'Musculoskeletal system and injury treatment',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Ophthalmology',
|
||||||
|
icon: Icons.remove_red_eye,
|
||||||
|
color: Colors.brown,
|
||||||
|
description: 'Eye care and vision treatment',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'ENT',
|
||||||
|
icon: Icons.hearing,
|
||||||
|
color: Colors.cyan,
|
||||||
|
description: 'Ear, nose, and throat specialist',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Psychiatry',
|
||||||
|
icon: Icons.psychology_outlined,
|
||||||
|
color: Colors.deepPurple,
|
||||||
|
description: 'Mental health and behavioral disorders',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Gynecology',
|
||||||
|
icon: Icons.pregnant_woman,
|
||||||
|
color: Colors.pinkAccent,
|
||||||
|
description: "Women's health and reproductive care",
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Urology',
|
||||||
|
icon: Icons.water_drop,
|
||||||
|
color: Colors.lightBlue,
|
||||||
|
description: 'Urinary tract and male reproductive health',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Endocrinology',
|
||||||
|
icon: Icons.biotech,
|
||||||
|
color: Colors.amber,
|
||||||
|
description: 'Hormone and metabolic disorders',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Oncology',
|
||||||
|
icon: Icons.bloodtype,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
description: 'Cancer diagnosis and treatment',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Rheumatology',
|
||||||
|
icon: Icons.accessibility,
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
description: 'Arthritis and autoimmune conditions',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Pulmonology',
|
||||||
|
icon: Icons.air,
|
||||||
|
color: Colors.lightGreen,
|
||||||
|
description: 'Respiratory system disorders',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Nephrology',
|
||||||
|
icon: Icons.water,
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
description: 'Kidney diseases and disorders',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Dentistry',
|
||||||
|
icon: Icons.cleaning_services,
|
||||||
|
color: Colors.cyan,
|
||||||
|
description: 'Oral health and dental care',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Physical Therapy',
|
||||||
|
icon: Icons.accessibility_new,
|
||||||
|
color: Colors.deepPurple,
|
||||||
|
description: 'Rehabilitation and physical medicine',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Sports Medicine',
|
||||||
|
icon: Icons.sports,
|
||||||
|
color: Colors.green,
|
||||||
|
description: 'Athletic injuries and performance',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Allergy & Immunology',
|
||||||
|
icon: Icons.sick,
|
||||||
|
color: Colors.orange,
|
||||||
|
description: 'Allergies and immune system disorders',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Pain Management',
|
||||||
|
icon: Icons.healing,
|
||||||
|
color: Colors.red,
|
||||||
|
description: 'Chronic pain treatment',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Sleep Medicine',
|
||||||
|
icon: Icons.bedtime,
|
||||||
|
color: Colors.indigo,
|
||||||
|
description: 'Sleep disorders and treatment',
|
||||||
|
),
|
||||||
|
Specialty(
|
||||||
|
name: 'Geriatrics',
|
||||||
|
icon: Icons.elderly,
|
||||||
|
color: Colors.brown,
|
||||||
|
description: 'Healthcare for elderly patients',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
late List<Specialty> _filteredSpecialties;
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
bool _isSearching = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_filteredSpecialties = _allSpecialties;
|
||||||
|
_searchController.addListener(_onSearchChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
|
final searchQuery = _searchController.text.toLowerCase();
|
||||||
|
setState(() {
|
||||||
|
_isSearching = searchQuery.isNotEmpty;
|
||||||
|
_filteredSpecialties = _allSpecialties
|
||||||
|
.where((specialty) =>
|
||||||
|
specialty.name.toLowerCase().contains(searchQuery) ||
|
||||||
|
specialty.description.toLowerCase().contains(searchQuery))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F7FF),
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildSliverAppBar(),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildSearchBar(),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: _buildSpecialtiesGrid(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSliverAppBar() {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 55,
|
||||||
|
floating: true,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
title: Text(
|
||||||
|
'Find a Specialist',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchBar() {
|
||||||
|
return FadeIn(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search specialties...',
|
||||||
|
hintStyle: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(Icons.search, color: Colors.grey),
|
||||||
|
suffixIcon: _isSearching
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSpecialtiesGrid() {
|
||||||
|
return SliverAnimationBuilder(
|
||||||
|
child: MasonryGridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: _filteredSpecialties.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final specialty = _filteredSpecialties[index];
|
||||||
|
return FadeInUp(
|
||||||
|
delay: Duration(milliseconds: 100 * index),
|
||||||
|
child: _buildSpecialtyCard(specialty),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSpecialtyCard(Specialty specialty) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
RouteNames.doctorListScreen,
|
||||||
|
arguments: {
|
||||||
|
'specialty': specialty.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: specialty.color.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
right: -20,
|
||||||
|
top: -20,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 40,
|
||||||
|
backgroundColor: specialty.color.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: specialty.color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
specialty.icon,
|
||||||
|
color: specialty.color,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
specialty.name,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
specialty.description,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SliverAnimationBuilder extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const SliverAnimationBuilder({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverAnimatedList(
|
||||||
|
initialItemCount: 1,
|
||||||
|
itemBuilder: (context, index, animation) {
|
||||||
|
return SlideInUp(
|
||||||
|
from: 50,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:animations/animations.dart';
|
||||||
|
import 'package:curved_navigation_bar/curved_navigation_bar.dart';
|
||||||
|
import 'package:medora/screens/patient_screens/patient_dashboard/patient_home_screen.dart';
|
||||||
|
import 'package:medora/screens/patient_screens/patient_dashboard/patient_profile_screen.dart';
|
||||||
|
|
||||||
|
class PatientDashboardScreen extends StatefulWidget {
|
||||||
|
const PatientDashboardScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PatientDashboardScreen> createState() => _PatientDashboardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PatientDashboardScreenState extends State<PatientDashboardScreen> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
final GlobalKey<CurvedNavigationBarState> _bottomNavigationKey = GlobalKey();
|
||||||
|
|
||||||
|
// Add your pages here
|
||||||
|
final List<Widget> _pages = [
|
||||||
|
const PatientHomeScreen(),
|
||||||
|
const Center(child: Text('Chat')), // Replace with your chat screen
|
||||||
|
const Center(child: Text('Records')), // Replace with your records screen
|
||||||
|
const PatientProfileScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: PageTransitionSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
transitionBuilder: (child, animation, secondaryAnimation) {
|
||||||
|
return FadeThroughTransition(
|
||||||
|
animation: animation,
|
||||||
|
secondaryAnimation: secondaryAnimation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _pages[_selectedIndex],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: CurvedNavigationBar(
|
||||||
|
key: _bottomNavigationKey,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
color: Colors.blue,
|
||||||
|
buttonBackgroundColor: Colors.blue,
|
||||||
|
height: 60,
|
||||||
|
index: _selectedIndex,
|
||||||
|
items: const [
|
||||||
|
Icon(Icons.home, size: 30, color: Colors.white),
|
||||||
|
Icon(Icons.chat_bubble, size: 30, color: Colors.white),
|
||||||
|
Icon(Icons.assignment, size: 30, color: Colors.white),
|
||||||
|
Icon(Icons.person, size: 30, color: Colors.white),
|
||||||
|
],
|
||||||
|
onTap: (index) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,695 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:medora/data/models/consultation_booking.dart';
|
||||||
|
import 'package:medora/data/services/consultation_booking_service.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
import 'package:medora/screens/patient_screens/appoinment_bookings/speciality_screen.dart';
|
||||||
|
|
||||||
|
class PatientHomeScreen extends StatefulWidget {
|
||||||
|
const PatientHomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PatientHomeScreen> createState() => _PatientHomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PatientHomeScreenState extends State<PatientHomeScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
final BookingService _bookingService = BookingService();
|
||||||
|
late Stream<List<Booking>> _bookingsStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
_animationController.forward();
|
||||||
|
|
||||||
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
final String userId = user.uid;
|
||||||
|
_bookingsStream = _bookingService.getPatientBookings(userId);
|
||||||
|
} else {
|
||||||
|
_bookingsStream = const Stream.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildSearchBar(),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildRealTimeCard(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildConsultationsSection(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildFindDoctorSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchBar() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(30.0),
|
||||||
|
bottomRight: Radius.circular(30.0),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blue.withOpacity(0.3),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search Doctor/Hospital/Symptoms',
|
||||||
|
hintStyle: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(Icons.search, color: Colors.blue),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRealTimeCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.blue[400]!, Colors.white],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blue.withOpacity(0.3),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Real-time care\nat your fingertips.',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: const Color.fromARGB(221, 67, 67, 67),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const SpecialtyScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.blue[700],
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 7),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
elevation: 5,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Start Consultation',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConsultationsSection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Upcoming Consultations',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey[800],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
height: 201,
|
||||||
|
child: StreamBuilder<List<Booking>>(
|
||||||
|
stream: _bookingsStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Loading consultations...',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline,
|
||||||
|
color: Colors.red[400], size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Error loading consultations',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.red[400],
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Implement refresh logic
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Try Again',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.blue[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bookings = snapshot.data ?? [];
|
||||||
|
|
||||||
|
if (bookings.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.calendar_today,
|
||||||
|
color: Colors.grey[400], size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No upcoming consultations',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to book consultation
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Book a Consultation',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.blue[700],
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: bookings.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final booking = bookings[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Hero(
|
||||||
|
tag: 'consultation_${booking.id}',
|
||||||
|
child: Material(
|
||||||
|
child: _consultationCard(
|
||||||
|
booking.profileImageUrl,
|
||||||
|
booking.doctorName,
|
||||||
|
'${DateFormat('EEE, MMM d, yyyy').format(booking.appointmentDate)}\n${booking.appointmentTime}',
|
||||||
|
booking.specialization,
|
||||||
|
booking.paymentStatus,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _consultationCard(
|
||||||
|
String? profileImageUrl,
|
||||||
|
String name,
|
||||||
|
String schedule,
|
||||||
|
String speciality,
|
||||||
|
PaymentStatus paymentStatus,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
width: 300,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.white, Colors.grey[50]!],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (profileImageUrl != null)
|
||||||
|
Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(profileImageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blue[300]!.withOpacity(0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.blue[400]!, Colors.blue[600]!],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blue[300]!.withOpacity(0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 36,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey[800],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
speciality,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getStatusColor(paymentStatus).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
_getStatusColor(paymentStatus).withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getStatusIcon(paymentStatus),
|
||||||
|
size: 14,
|
||||||
|
color: _getStatusColor(paymentStatus),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_getStatusText(paymentStatus),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _getStatusColor(paymentStatus),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue[50],
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.blue[100]!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.blue[700],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
schedule,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.blue[700],
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getStatusIcon(PaymentStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case PaymentStatus.completed:
|
||||||
|
return Icons.check_circle;
|
||||||
|
case PaymentStatus.pending:
|
||||||
|
return Icons.access_time;
|
||||||
|
case PaymentStatus.failed:
|
||||||
|
return Icons.error;
|
||||||
|
default:
|
||||||
|
return Icons.info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStatusColor(PaymentStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case PaymentStatus.pending:
|
||||||
|
return Colors.orange;
|
||||||
|
case PaymentStatus.completed:
|
||||||
|
return Colors.green;
|
||||||
|
case PaymentStatus.failed:
|
||||||
|
return Colors.red;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getStatusText(PaymentStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case PaymentStatus.pending:
|
||||||
|
return 'Payment Pending';
|
||||||
|
case PaymentStatus.completed:
|
||||||
|
return 'Confirmed';
|
||||||
|
case PaymentStatus.failed:
|
||||||
|
return 'Payment Failed';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFindDoctorSection() {
|
||||||
|
final specialistData = [
|
||||||
|
{
|
||||||
|
'icon': Icons.local_hospital,
|
||||||
|
'label': 'General',
|
||||||
|
'color': Colors.blue,
|
||||||
|
'description': 'Primary Healthcare'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'icon': Icons.remove_red_eye,
|
||||||
|
'label': 'Eye',
|
||||||
|
'color': Colors.indigo,
|
||||||
|
'description': 'Vision Care'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'icon': Icons.medical_services,
|
||||||
|
'label': 'Dental',
|
||||||
|
'color': Colors.amber,
|
||||||
|
'description': 'Oral Health'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'icon': Icons.favorite,
|
||||||
|
'label': 'Cardio',
|
||||||
|
'color': Colors.red,
|
||||||
|
'description': 'Heart Specialist'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'icon': Icons.psychology,
|
||||||
|
'label': 'Mental',
|
||||||
|
'color': Colors.green,
|
||||||
|
'description': 'Mental Health'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'icon': Icons.child_care,
|
||||||
|
'label': 'Pediatric',
|
||||||
|
'color': Colors.purple,
|
||||||
|
'description': 'Child Care'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'icon': Icons.elderly,
|
||||||
|
'label': 'Geriatric',
|
||||||
|
'color': Colors.teal,
|
||||||
|
'description': 'Senior Care'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'icon': Icons.fitness_center,
|
||||||
|
'label': 'Physio',
|
||||||
|
'color': Colors.orange,
|
||||||
|
'description': 'Physical Therapy'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Text(
|
||||||
|
'Find Specialists',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final data in specialistData)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: _specialistCard(
|
||||||
|
icon: data['icon'] as IconData,
|
||||||
|
label: data['label'] as String,
|
||||||
|
color: data['color'] as Color,
|
||||||
|
description: data['description'] as String,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_forward, color: Colors.blue),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const SpecialtyScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _specialistCard({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
required String description,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
RouteNames.doctorListScreen,
|
||||||
|
arguments: {
|
||||||
|
'specialty': label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:medora/data/services/patient_registration_service.dart';
|
||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
import 'package:medora/data/models/patient.dart';
|
||||||
|
|
||||||
|
class PatientProfileScreen extends StatefulWidget {
|
||||||
|
const PatientProfileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PatientProfileScreen> createState() => _PatientProfileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PatientProfileScreenState extends State<PatientProfileScreen> {
|
||||||
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
|
PatientModel? _patientProfile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchPatientProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPatientProfile() async {
|
||||||
|
final patientProfile = await PatientProfileService.getPatientProfile();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_patientProfile = patientProfile;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildProfileHeader(),
|
||||||
|
_buildProfileOptions(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProfileHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(0xFF00BCD4),
|
||||||
|
Color(0xFF2196F3),
|
||||||
|
],
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(20),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
image: _patientProfile?.profileImageUrl != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: NetworkImage(_patientProfile!.profileImageUrl!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: _patientProfile?.profileImageUrl == null
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
_patientProfile != null && _patientProfile!.name != null
|
||||||
|
? _patientProfile!.name![0].toUpperCase()
|
||||||
|
: '',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_patientProfile != null && _patientProfile!.name != null
|
||||||
|
? _patientProfile!.name!
|
||||||
|
: 'Create your profile',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 30,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProfileOptions() {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
spreadRadius: 1,
|
||||||
|
blurRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildOptionTile(
|
||||||
|
'Medical Profile',
|
||||||
|
Icons.medical_information_outlined,
|
||||||
|
onTap: () {
|
||||||
|
// Add navigation or action
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildOptionTile(
|
||||||
|
'Sign Out',
|
||||||
|
Icons.logout,
|
||||||
|
onTap: () {
|
||||||
|
_signOut();
|
||||||
|
},
|
||||||
|
iconColor: Colors.blue,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOptionTile(String title, IconData icon,
|
||||||
|
{required VoidCallback onTap, Color? iconColor}) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
icon,
|
||||||
|
color: iconColor ?? Colors.grey,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _signOut() async {
|
||||||
|
try {
|
||||||
|
await _auth.signOut();
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pushReplacementNamed(RouteNames.launch);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error signing out: $e");
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Failed to log out. Please try again.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/screens/patient_screens/patient_landing_screen.dart
Normal file
94
lib/screens/patient_screens/patient_landing_screen.dart
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import 'package:medora/route/route_names.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class PatientLandingScreen extends StatelessWidget {
|
||||||
|
const PatientLandingScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.teal.shade100, Colors.white],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushNamed(
|
||||||
|
RouteNames.patientDashboardScreen);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Skip',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.teal.shade300,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Image.asset(
|
||||||
|
'images/patient-avathar.png',
|
||||||
|
height: 200,
|
||||||
|
width: 200,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Set your medical profile',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushNamed(
|
||||||
|
RouteNames.patientRegistrationScreen);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Continue',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 18, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,285 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:medora/controllers/patient_controller.dart';
|
||||||
|
import 'package:medora/data/models/patient.dart';
|
||||||
|
|
||||||
|
class FamilyMembersEditScreen extends StatefulWidget {
|
||||||
|
final FamilyMember? familyMember;
|
||||||
|
final PatientController controller;
|
||||||
|
|
||||||
|
const FamilyMembersEditScreen(
|
||||||
|
{super.key, this.familyMember, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FamilyMembersEditScreen> createState() =>
|
||||||
|
_FamilyMembersEditScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FamilyMembersEditScreenState extends State<FamilyMembersEditScreen> {
|
||||||
|
late TextEditingController nameController;
|
||||||
|
late TextEditingController relationController;
|
||||||
|
late TextEditingController genderController;
|
||||||
|
late TextEditingController dobController;
|
||||||
|
Map<String, String> errors = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
nameController =
|
||||||
|
TextEditingController(text: widget.familyMember?.name ?? '');
|
||||||
|
relationController =
|
||||||
|
TextEditingController(text: widget.familyMember?.relation ?? '');
|
||||||
|
genderController =
|
||||||
|
TextEditingController(text: widget.familyMember?.gender ?? '');
|
||||||
|
dobController = TextEditingController(
|
||||||
|
text: widget.familyMember?.dateOfBirth?.toString().split(' ')[0] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Edit Family Member'),
|
||||||
|
actions: _buildAppBarActions(),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTextField(nameController, 'Name', Icons.person, 'name'),
|
||||||
|
_buildDropdownField(
|
||||||
|
'Relation',
|
||||||
|
relationController.text,
|
||||||
|
(String? newValue) {
|
||||||
|
setState(() {
|
||||||
|
relationController.text = newValue ?? '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Icons.family_restroom,
|
||||||
|
),
|
||||||
|
_buildDropdownField(
|
||||||
|
'Gender',
|
||||||
|
genderController.text,
|
||||||
|
(String? newValue) {
|
||||||
|
setState(() {
|
||||||
|
genderController.text = newValue ?? '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Icons.transgender,
|
||||||
|
),
|
||||||
|
_buildDateField(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdownField(
|
||||||
|
String label, String value, Function(String?) onChanged, IconData icon) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
prefixIcon: Icon(icon, color: Colors.blue),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
value: value.isEmpty ? null : value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
items: label == 'Relation'
|
||||||
|
? <String>['Father', 'Mother', 'Son', 'Daughter', 'Other']
|
||||||
|
.map<DropdownMenuItem<String>>((String value) {
|
||||||
|
return DropdownMenuItem<String>(
|
||||||
|
value: value,
|
||||||
|
child: Text(value),
|
||||||
|
);
|
||||||
|
}).toList()
|
||||||
|
: <String>['Male', 'Female', 'Other']
|
||||||
|
.map<DropdownMenuItem<String>>((String value) {
|
||||||
|
return DropdownMenuItem<String>(
|
||||||
|
value: value,
|
||||||
|
child: Text(value),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDateField(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: dobController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Date of Birth',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.calendar_today, color: Colors.blue),
|
||||||
|
),
|
||||||
|
readOnly: true,
|
||||||
|
onTap: () async {
|
||||||
|
DateTime? pickedDate = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||||
|
firstDate: DateTime(1900),
|
||||||
|
lastDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (pickedDate != null) {
|
||||||
|
setState(() {
|
||||||
|
dobController.text = pickedDate.toString().split(' ')[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateFields() {
|
||||||
|
errors.clear();
|
||||||
|
|
||||||
|
if (nameController.text.trim().isEmpty) {
|
||||||
|
errors['name'] = 'Name is required';
|
||||||
|
} else if (nameController.text.trim().length < 2) {
|
||||||
|
errors['name'] = 'Name must be at least 2 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationController.text.isEmpty) {
|
||||||
|
errors['relation'] = 'Please select a relation';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (genderController.text.isEmpty) {
|
||||||
|
errors['gender'] = 'Please select a gender';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dobController.text.isEmpty) {
|
||||||
|
errors['dob'] = 'Date of Birth is required';
|
||||||
|
} else {
|
||||||
|
final dob = DateTime.tryParse(dobController.text);
|
||||||
|
if (dob == null) {
|
||||||
|
errors['dob'] = 'Invalid date format';
|
||||||
|
} else if (dob.isAfter(DateTime.now())) {
|
||||||
|
errors['dob'] = 'Date of Birth cannot be in the future';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {});
|
||||||
|
return errors.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _buildTextField(
|
||||||
|
TextEditingController controller,
|
||||||
|
String label,
|
||||||
|
IconData icon,
|
||||||
|
String errorKey,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
icon,
|
||||||
|
color: errors.containsKey(errorKey) ? Colors.red : Colors.blue,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color:
|
||||||
|
errors.containsKey(errorKey) ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color:
|
||||||
|
errors.containsKey(errorKey) ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color:
|
||||||
|
errors.containsKey(errorKey) ? Colors.red : Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (errors.containsKey(errorKey))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4, left: 12),
|
||||||
|
child: Text(
|
||||||
|
errors[errorKey]!,
|
||||||
|
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildAppBarActions() {
|
||||||
|
return [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_validateFields()) {
|
||||||
|
FamilyMember newMember = FamilyMember(
|
||||||
|
name: nameController.text,
|
||||||
|
relation: relationController.text,
|
||||||
|
gender: genderController.text,
|
||||||
|
dateOfBirth: DateTime.tryParse(dobController.text),
|
||||||
|
);
|
||||||
|
Navigator.pop(context, newMember);
|
||||||
|
} else {
|
||||||
|
_showValidationErrors();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Done', style: TextStyle(color: Colors.blue)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
nameController.dispose();
|
||||||
|
relationController.dispose();
|
||||||
|
genderController.dispose();
|
||||||
|
dobController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,333 @@
|
|||||||
|
import 'package:medora/controllers/patient_controller.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:country_state_city_picker/country_state_city_picker.dart';
|
||||||
|
|
||||||
|
class PatientAddressScreen extends StatefulWidget {
|
||||||
|
final PatientController? controller;
|
||||||
|
|
||||||
|
const PatientAddressScreen({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PatientAddressScreen> createState() => _PatientAddressScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PatientAddressScreenState extends State<PatientAddressScreen> {
|
||||||
|
late PatientController _controller;
|
||||||
|
late TextEditingController _houseNoController;
|
||||||
|
late TextEditingController _lineController;
|
||||||
|
late TextEditingController _townController;
|
||||||
|
late TextEditingController _pincodeController;
|
||||||
|
final String country = 'India';
|
||||||
|
String? state;
|
||||||
|
String? city;
|
||||||
|
String? addressType;
|
||||||
|
final Map<String, String> _errors = {};
|
||||||
|
bool _hasErrors = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = widget.controller ?? PatientController();
|
||||||
|
_loadSavedData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadSavedData() {
|
||||||
|
final address = _controller.model.address;
|
||||||
|
_houseNoController = TextEditingController(text: address.houseNo ?? '');
|
||||||
|
_lineController = TextEditingController(text: address.line ?? '');
|
||||||
|
_townController = TextEditingController(text: address.town ?? '');
|
||||||
|
_pincodeController = TextEditingController(text: address.pincode ?? '');
|
||||||
|
|
||||||
|
state = address.state;
|
||||||
|
city = address.city;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Address'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saveAndExit,
|
||||||
|
child: const Text('Done', style: TextStyle(color: Colors.blue)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildSectionContainer(
|
||||||
|
'Address Information',
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
_buildTextField(
|
||||||
|
'House No.',
|
||||||
|
_houseNoController,
|
||||||
|
(value) => widget.controller!.updateHouseNo(value),
|
||||||
|
icon: Icons.home_outlined,
|
||||||
|
errorKey: 'houseNo',
|
||||||
|
),
|
||||||
|
_buildTextField(
|
||||||
|
'Address Line',
|
||||||
|
_lineController,
|
||||||
|
(value) => widget.controller!.updateLine(value),
|
||||||
|
icon: Icons.location_on_outlined,
|
||||||
|
errorKey: 'line',
|
||||||
|
),
|
||||||
|
_buildTextField(
|
||||||
|
'Town (Optional)',
|
||||||
|
_townController,
|
||||||
|
(value) => widget.controller!.updateTown(value),
|
||||||
|
icon: Icons.location_city_outlined,
|
||||||
|
),
|
||||||
|
_buildTextField(
|
||||||
|
'Pincode',
|
||||||
|
_pincodeController,
|
||||||
|
(value) => widget.controller!.updatePincode(value),
|
||||||
|
icon: Icons.pin_drop_outlined,
|
||||||
|
errorKey: 'pincode',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSectionContainer(
|
||||||
|
'Location',
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
_buildCountrySelection(),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SelectState(
|
||||||
|
onCountryChanged: (value) {
|
||||||
|
setState(() {});
|
||||||
|
widget.controller!.updateCountry('India');
|
||||||
|
},
|
||||||
|
onStateChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
state = value;
|
||||||
|
});
|
||||||
|
widget.controller!.updateState(value);
|
||||||
|
},
|
||||||
|
onCityChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
city = value;
|
||||||
|
});
|
||||||
|
widget.controller!.updateCity(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (state != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Text('State: $state',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14, color: Colors.black87)),
|
||||||
|
),
|
||||||
|
if (city != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Text('City: $city',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14, color: Colors.black87)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateFields() {
|
||||||
|
setState(() {
|
||||||
|
_errors.clear();
|
||||||
|
_hasErrors = false;
|
||||||
|
|
||||||
|
if (_houseNoController.text.trim().isEmpty) {
|
||||||
|
_errors['houseNo'] = 'House No. is required';
|
||||||
|
_hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lineController.text.trim().isEmpty) {
|
||||||
|
_errors['line'] = 'Address Line is required';
|
||||||
|
_hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pincode = _pincodeController.text.trim();
|
||||||
|
if (pincode.isEmpty) {
|
||||||
|
_errors['pincode'] = 'Pincode is required';
|
||||||
|
_hasErrors = true;
|
||||||
|
} else if (!RegExp(r'^[1-9][0-9]{5}$').hasMatch(pincode)) {
|
||||||
|
_errors['pincode'] = 'Enter a valid 6-digit pincode';
|
||||||
|
_hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == null || state!.isEmpty) {
|
||||||
|
_errors['state'] = 'State is required';
|
||||||
|
_hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (city == null || city!.isEmpty) {
|
||||||
|
_errors['city'] = 'City is required';
|
||||||
|
_hasErrors = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return !_hasErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionContainer(String title, Widget content) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.blueGrey.withOpacity(0.5),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
content,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(
|
||||||
|
String label,
|
||||||
|
TextEditingController controller,
|
||||||
|
Function(String) onChanged, {
|
||||||
|
required IconData icon,
|
||||||
|
String? errorKey,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
prefixIcon: Icon(icon,
|
||||||
|
color: _errors.containsKey(errorKey)
|
||||||
|
? Colors.red
|
||||||
|
: Colors.blueAccent),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: _errors.containsKey(errorKey) ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: _errors.containsKey(errorKey) ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: _errors.containsKey(errorKey)
|
||||||
|
? Colors.red
|
||||||
|
: Colors.blueAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorText: _errors[errorKey],
|
||||||
|
),
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCountrySelection() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Country:',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('India', style: TextStyle(fontSize: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveAndExit() {
|
||||||
|
if (_validateFields()) {
|
||||||
|
widget.controller!.updateHouseNo(_houseNoController.text);
|
||||||
|
widget.controller!.updateLine(_lineController.text);
|
||||||
|
widget.controller!.updateTown(_townController.text);
|
||||||
|
widget.controller!.updatePincode(_pincodeController.text);
|
||||||
|
widget.controller!.updateCountry(country);
|
||||||
|
widget.controller!.updateState(state ?? '');
|
||||||
|
widget.controller!.updateCity(city ?? '');
|
||||||
|
|
||||||
|
widget.controller!.updatePatientData();
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
} else {
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_houseNoController.dispose();
|
||||||
|
_lineController.dispose();
|
||||||
|
_townController.dispose();
|
||||||
|
_pincodeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,308 @@
|
|||||||
|
import 'package:medora/data/models/patient.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:medora/screens/patient_screens/registration_screens/family_members_edit_screen.dart';
|
||||||
|
import '../../../controllers/patient_controller.dart';
|
||||||
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
|
|
||||||
|
class PatientFamilyMembersScreen extends StatefulWidget {
|
||||||
|
final PatientController controller;
|
||||||
|
const PatientFamilyMembersScreen({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PatientFamilyMembersScreen> createState() =>
|
||||||
|
_PatientFamilyMembersScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PatientFamilyMembersScreenState
|
||||||
|
extends State<PatientFamilyMembersScreen> {
|
||||||
|
bool isLoading = false;
|
||||||
|
final int maxFamilyMembers = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'Family Members',
|
||||||
|
style: TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||||
|
onPressed: () {
|
||||||
|
if (_validateFamilyMembers()) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: _buildAppBarActions(),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: widget.controller.model.familyMembers.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return FamilyMemberCard(
|
||||||
|
familyMember: widget.controller.model.familyMembers[index],
|
||||||
|
onEdit: () => _editFamilyMember(index),
|
||||||
|
onDelete: () => _deleteFamilyMember(index),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _addFamilyMember,
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
child: const Icon(Icons.add, color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildAppBarActions() {
|
||||||
|
return [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_validateFamilyMembers()) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Done',
|
||||||
|
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateFamilyMembers() {
|
||||||
|
if (widget.controller.model.familyMembers.isEmpty) {
|
||||||
|
_showValidationError('Please add at least one family member');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final relations = widget.controller.model.familyMembers
|
||||||
|
.map((member) => member.relation?.toLowerCase())
|
||||||
|
.toList();
|
||||||
|
if (relations.toSet().length != relations.length) {
|
||||||
|
_showValidationError('Duplicate relations are not allowed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showValidationError(String message) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Validation Error'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFamilyMember() {
|
||||||
|
if (widget.controller.model.familyMembers.length >= maxFamilyMembers) {
|
||||||
|
_showValidationError('Maximum $maxFamilyMembers family members allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => FamilyMembersEditScreen(
|
||||||
|
controller: widget.controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).then((newMember) {
|
||||||
|
if (newMember != null) {
|
||||||
|
setState(() {
|
||||||
|
widget.controller.addFamilyMember(newMember);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editFamilyMember(int index) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => FamilyMembersEditScreen(
|
||||||
|
controller: widget.controller,
|
||||||
|
familyMember: widget.controller.model.familyMembers[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).then((editedMember) {
|
||||||
|
if (editedMember != null) {
|
||||||
|
setState(() {
|
||||||
|
widget.controller.updateFamilyMember(index, editedMember);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteFamilyMember(int index) {
|
||||||
|
if (widget.controller.model.familyMembers.length <= 1) {
|
||||||
|
_showValidationError('At least one family member is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Family Member'),
|
||||||
|
content:
|
||||||
|
const Text('Are you sure you want to delete this family member?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
widget.controller.deleteFamilyMember(index);
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FamilyMemberCard extends StatelessWidget {
|
||||||
|
final FamilyMember familyMember;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const FamilyMemberCard({
|
||||||
|
super.key,
|
||||||
|
required this.familyMember,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _buildInfoRow(IconData icon, String label, String? value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: Colors.blueGrey),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value ?? 'Not provided',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
color: value == null || value.isEmpty
|
||||||
|
? Colors.redAccent
|
||||||
|
: Colors.black87,
|
||||||
|
fontStyle: value == null || value.isEmpty
|
||||||
|
? FontStyle.italic
|
||||||
|
: FontStyle.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Slidable(
|
||||||
|
key: ValueKey(familyMember),
|
||||||
|
endActionPane: ActionPane(
|
||||||
|
motion: const ScrollMotion(),
|
||||||
|
extentRatio: 0.3,
|
||||||
|
children: [
|
||||||
|
SlidableAction(
|
||||||
|
onPressed: (context) => onEdit(),
|
||||||
|
foregroundColor: Colors.blue,
|
||||||
|
icon: Icons.edit,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
spacing: 0,
|
||||||
|
),
|
||||||
|
SlidableAction(
|
||||||
|
onPressed: (context) => onDelete(),
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
icon: Icons.delete,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
spacing: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Card(
|
||||||
|
elevation: 4,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.blueGrey[50],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(Icons.person, 'Name:', familyMember.name),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.transgender, 'Gender:', familyMember.gender),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.cake,
|
||||||
|
'Date of Birth:',
|
||||||
|
familyMember.dateOfBirth?.toString().split(' ')[0] ??
|
||||||
|
'Not provided',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.family_restroom, 'Relation:', familyMember.relation),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,689 @@
|
|||||||
|
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) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
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: _handleProfileSave,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleProfileSave() async {
|
||||||
|
if (_validateAllFields()) {
|
||||||
|
try {
|
||||||
|
final bool isSuccess = await _controller.savePatientData();
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showResultDialog(isSuccess);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showResultDialog(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_showValidationErrors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
style: const TextStyle(color: Colors.black87),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
|||||||
set(BINARY_NAME "telemednet")
|
set(BINARY_NAME "telemednet")
|
||||||
# The unique GTK application identifier for this application. See:
|
# The unique GTK application identifier for this application. See:
|
||||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||||
set(APPLICATION_ID "com.cosqnet.telemednet")
|
set(APPLICATION_ID "com.cosqnet.medoraprovider")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
|
|||||||
@ -385,7 +385,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/telemednet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/telemednet";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/telemednet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/telemednet";
|
||||||
@ -399,7 +399,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/telemednet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/telemednet";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/telemednet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/telemednet";
|
||||||
@ -413,7 +413,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/telemednet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/telemednet";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/telemednet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/telemednet";
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
PRODUCT_NAME = telemednet
|
PRODUCT_NAME = telemednet
|
||||||
|
|
||||||
// The application's bundle identifier
|
// The application's bundle identifier
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.telemednet
|
PRODUCT_BUNDLE_IDENTIFIER = com.cosqnet.medoraprovider
|
||||||
|
|
||||||
// The copyright displayed in application information
|
// The copyright displayed in application information
|
||||||
PRODUCT_COPYRIGHT = Copyright © 2024 com.cosqnet. All rights reserved.
|
PRODUCT_COPYRIGHT = Copyright © 2024 com.cosqnet. All rights reserved.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user