feat: Refactor code structure and optimize performance across multiple modules

This commit is contained in:
Achintha Isuru
2025-11-17 23:29:28 -05:00
parent 831570f2e0
commit a64cbd9edf
1508 changed files with 105319 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
const String getPersonalInfoSchema = '''
query GetPersonalInfo {
client_profile {
id
first_name
last_name
title
avatar
auth_info {
email
phone
}
}
}
''';
const String updateStaffMutationSchema = '''
mutation UpdateStaffPersonalInfo(\$input: UpdateClientProfileInput!) {
update_client_profile(input: \$input) {
id
first_name
last_name
title
avatar
auth_info {
email
phone
}
}
}
''';
const String updateStaffMutationWithAvatarSchema = '''
mutation UpdateStaffPersonalInfo(\$input: UpdateClientProfileInput!, \$file: Upload!) {
update_client_profile(input: \$input) {
id
first_name
last_name
title
avatar
auth_info {
email
phone
}
}
upload_client_avatar(file: \$file)
}
''';
const deactivateClientProfileSchema = '''
mutation deactivate_client_profile {
deactivate_client_profile(){
id
}
}
''';

View File

@@ -0,0 +1,94 @@
import 'dart:developer';
import 'dart:io';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/core/data/models/client/client.dart';
import 'package:krow/features/profile/data/gql_schemas.dart';
@singleton
class ClientProfileApiProvider {
ClientProfileApiProvider(this._client);
final ApiClient _client;
Stream<ClientModel> getMeWithCache() async* {
await for (var response in _client.queryWithCache(
schema: getPersonalInfoSchema,
)) {
if (response == null) continue;
if (response.hasException) {
throw Exception(response.exception.toString());
}
try {
final profileData =
response.data?['client_profile'] as Map<String, dynamic>? ?? {};
if (profileData.isEmpty) continue;
yield ClientModel.fromJson(profileData);
} catch (except) {
log(
'Exception in StaffApi on getMeWithCache()',
error: except,
);
continue;
}
}
}
Future<ClientModel> updatePersonalInfo({
required String firstName,
required String lastName,
required String phone,
String? avatarPath,
}) async {
MultipartFile? multipartFile;
if (avatarPath != null) {
var byteData = File(avatarPath).readAsBytesSync();
multipartFile = MultipartFile.fromBytes(
'file',
byteData,
filename: '${DateTime.now().millisecondsSinceEpoch}.jpg',
contentType: MediaType('image', 'jpg'),
);
}
final QueryResult result = await _client.mutate(
schema: multipartFile != null
? updateStaffMutationWithAvatarSchema
: updateStaffMutationSchema,
body: {
'input': {
'first_name': firstName.trim(),
'last_name': lastName.trim(),
'phone': phone.trim(),
},
if (multipartFile != null) 'file': multipartFile,
},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return ClientModel.fromJson(
(result.data as Map<String, dynamic>)['update_client_profile'],
);
}
Future<void> deactivateClientProfile() async {
final QueryResult result = await _client.mutate(
schema: deactivateClientProfileSchema,
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
}
}

View File

@@ -0,0 +1,14 @@
import 'package:krow/core/data/models/client/client.dart';
abstract class ClientProfileRepository {
Stream<ClientModel> getPersonalInfo();
Future<ClientModel> updatePersonalInfo({
required String firstName,
required String lastName,
required String phone,
String? avatarPath,
});
Future<void> deactivateProfile();
}

View File

@@ -0,0 +1,151 @@
import 'dart:developer';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:krow/core/application/common/validators/email_validator.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/core/data/models/client/client.dart';
import 'package:krow/features/profile/data/profile_repository.dart';
part 'personal_info_event.dart';
part 'personal_info_state.dart';
class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState> {
PersonalInfoBloc() : super(const PersonalInfoState()) {
on<InitializeProfileInfoEvent>(_onInitializeProfileInfoEvent);
on<UpdateProfileImage>(_onUpdateProfileImage);
on<UpdateFirstName>(_onUpdateFirstName);
on<UpdateLastName>(_onUpdateLastName);
on<UpdateEmail>(_onUpdateEmail);
on<UpdatePhoneNumber>(_onUpdatePhoneNumber);
on<SaveProfileChanges>(_onSaveProfileChanges);
on<DeactivateProfile>(_onDeactivateProfile);
}
Future<void> _onInitializeProfileInfoEvent(
InitializeProfileInfoEvent event,
Emitter<PersonalInfoState> emit,
) async {
emit(
state.copyWith(
status: StateStatus.idle,
),
);
await for (final currentProfileData
in getIt<ClientProfileRepository>().getPersonalInfo()) {
try {
emit(
state.copyWith(
firstName: currentProfileData.firstName,
lastName: currentProfileData.lastName,
email: currentProfileData.authInfo?.email,
profileImageUrl: currentProfileData.avatar,
phoneNumber: currentProfileData.authInfo?.phone,
isUpdateReceived: true,
status: StateStatus.idle,
),
);
} catch (except) {
log(except.toString());
} finally {
emit(state.copyWith(isUpdateReceived: false));
}
}
if (state.status == StateStatus.loading) {
emit(state.copyWith(status: StateStatus.idle));
}
}
void _onUpdateProfileImage(
UpdateProfileImage event,
Emitter<PersonalInfoState> emit,
) {
emit(state.copyWith(profileImagePath: event.imagePath));
}
void _onUpdateFirstName(
UpdateFirstName event,
Emitter<PersonalInfoState> emit,
) {
emit(state.copyWith(firstName: event.firstName));
}
void _onUpdateLastName(
UpdateLastName event,
Emitter<PersonalInfoState> emit,
) {
emit(state.copyWith(lastName: event.lastName));
}
void _onUpdateEmail(
UpdateEmail event,
Emitter<PersonalInfoState> emit,
) {
final error = EmailValidator.validate(event.email);
emit(
state.copyWith(
email: event.email,
emailValidationError: error ?? '',
),
);
}
void _onUpdatePhoneNumber(
UpdatePhoneNumber event,
Emitter<PersonalInfoState> emit,
) {
emit(state.copyWith(phoneNumber: event.phoneNumber));
}
Future<void> _onSaveProfileChanges(
SaveProfileChanges event,
Emitter<PersonalInfoState> emit,
) async {
if (!state.isValid) {
emit(state.copyWith(status: StateStatus.error));
return;
}
emit(state.copyWith(status: StateStatus.loading));
ClientModel? response;
try {
response = await getIt<ClientProfileRepository>().updatePersonalInfo(
firstName: state.firstName,
lastName: state.lastName,
phone: state.phoneNumber,
avatarPath: state.profileImagePath,
);
} finally {
emit(
state.copyWith(
status: response != null ? StateStatus.success : StateStatus.idle,
),
);
}
}
Future<void> _onDeactivateProfile(
DeactivateProfile event,
Emitter<PersonalInfoState> emit,
) async {
emit(state.copyWith(status: StateStatus.loading));
try {
await getIt<ClientProfileRepository>().deactivateProfile();
} finally {
emit(state.copyWith(status: StateStatus.idle, deleted: true));
}
}
}

View File

@@ -0,0 +1,42 @@
part of 'personal_info_bloc.dart';
@immutable
sealed class PersonalInfoEvent {}
class InitializeProfileInfoEvent extends PersonalInfoEvent {
InitializeProfileInfoEvent();
}
class UpdateProfileImage extends PersonalInfoEvent {
UpdateProfileImage(this.imagePath);
final String imagePath;
}
class UpdateFirstName extends PersonalInfoEvent {
UpdateFirstName(this.firstName);
final String firstName;
}
class UpdateLastName extends PersonalInfoEvent {
UpdateLastName(this.lastName);
final String lastName;
}
class UpdateEmail extends PersonalInfoEvent {
UpdateEmail(this.email);
final String email;
}
class UpdatePhoneNumber extends PersonalInfoEvent {
UpdatePhoneNumber(this.phoneNumber);
final String phoneNumber;
}
class SaveProfileChanges extends PersonalInfoEvent {}
class DeactivateProfile extends PersonalInfoEvent {}

View File

@@ -0,0 +1,71 @@
part of 'personal_info_bloc.dart';
@immutable
class PersonalInfoState {
const PersonalInfoState({
this.firstName = '',
this.lastName = '',
this.email = '',
this.phoneNumber = '',
this.profileImageUrl,
this.profileImagePath,
this.isUpdateReceived = false,
this.status = StateStatus.idle,
this.emailValidationError = '',
this.deleted = false
});
final String firstName;
final String lastName;
final String email;
final String phoneNumber;
final String? profileImageUrl;
final String? profileImagePath;
final bool isUpdateReceived;
final StateStatus status;
final String emailValidationError;
final bool deleted;
PersonalInfoState copyWith({
String? firstName,
String? lastName,
String? email,
String? phoneNumber,
String? profileImageUrl,
String? profileImagePath,
bool? isUpdateReceived,
StateStatus? status,
String? emailValidationError,
bool? deleted,
}) {
return PersonalInfoState(
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
email: email ?? this.email,
phoneNumber: phoneNumber ?? this.phoneNumber,
profileImagePath: profileImagePath ?? this.profileImagePath,
profileImageUrl: profileImageUrl ?? this.profileImageUrl,
isUpdateReceived: isUpdateReceived ?? this.isUpdateReceived,
status: status ?? this.status,
deleted: deleted ?? this.deleted,
emailValidationError: emailValidationError ?? this.emailValidationError,
);
}
bool get isFilled {
return firstName.isNotEmpty && lastName.isNotEmpty && email.isNotEmpty;
// Phone should not be validated on initial profile setup
//(!isInEditMode || phoneNumber.isNotEmpty) &&
//photo is optional
// (profileImagePath != null || profileImageUrl != null);
}
bool get isValid {
return firstName.isNotEmpty &&
lastName.isNotEmpty &&
emailValidationError.isEmpty &&
// Phone should not be validated on initial profile setup
//(!isInEditMode || phoneNumber.isNotEmpty) &&
(profileImagePath != null || profileImageUrl != null);
}
}

View File

@@ -0,0 +1,45 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:krow/core/data/models/client/client.dart';
import 'package:krow/features/profile/data/profile_api_source.dart';
import 'package:krow/features/profile/data/profile_repository.dart';
@Singleton(as: ClientProfileRepository)
class ClientProfileRepositoryImpl implements ClientProfileRepository {
ClientProfileRepositoryImpl({
required ClientProfileApiProvider apiProvider,
}) : _apiProvider = apiProvider;
final ClientProfileApiProvider _apiProvider;
@override
Stream<ClientModel> getPersonalInfo() {
return _apiProvider.getMeWithCache();
}
@override
Future<ClientModel> updatePersonalInfo({
required String firstName,
required String lastName,
required String phone,
String? avatarPath,
}) {
try {
return _apiProvider.updatePersonalInfo(
firstName: firstName,
lastName: lastName,
phone: phone,
avatarPath: avatarPath,
);
} catch (exception) {
log((exception as Error).stackTrace.toString());
rethrow;
}
}
@override
Future<void> deactivateProfile() {
return _apiProvider.deactivateClientProfile();
}
}

View File

@@ -0,0 +1,91 @@
import 'package:auto_route/auto_route.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_loading_overlay.dart';
import 'package:krow/features/profile/domain/bloc/personal_info_bloc.dart';
import 'package:krow/features/profile/presentation/widgets/personal_info_form.dart';
@RoutePage()
class PersonalInfoScreen extends StatelessWidget {
const PersonalInfoScreen({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'Edit Profile',
),
body: ScrollLayoutHelper(
upperWidget: const PersonalInfoForm(),
lowerWidget: BlocConsumer<PersonalInfoBloc, PersonalInfoState>(
buildWhen: (previous, current) =>
previous.status != current.status ||
previous.isFilled != current.isFilled,
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == StateStatus.success) {}
if(state.deleted){
FirebaseAuth.instance.signOut();
context.router.replace(const SplashRoute());
}
},
builder: (context, state) {
return Column(
children: [
KwLoadingOverlay(
shouldShowLoading: state.status == StateStatus.loading,
child: KwButton.primary(
label: 'Save edits',
height: 52,
disabled: !state.isFilled,
onPressed: () {
context
.read<PersonalInfoBloc>()
.add(SaveProfileChanges());
},
),
),
Gap(12),
KwButton.outlinedPrimary(
label: 'Delete Account', onPressed: () {
_showDeleteAccDialog(context);
})
.copyWith(color: AppColors.statusError)
],
);
},
),
),
);
}
void _showDeleteAccDialog(BuildContext context) {
KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.negative,
title: 'Are you sure you want to delete account?',
message:
'Deleting your account is permanent and cannot be undone. You will lose all your data, including saved preferences, history, and any content associated with your account',
primaryButtonLabel: 'Delete Account',
secondaryButtonLabel: 'Cancel',
onPrimaryButtonPressed: (dialogContext) {
Navigator.pop(dialogContext);
BlocProvider.of<PersonalInfoBloc>(context)
.add(DeactivateProfile());
});
}
}

View File

@@ -0,0 +1,177 @@
import 'package:auto_route/auto_route.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/profile/domain/bloc/personal_info_bloc.dart';
@RoutePage()
class ProfilePreviewScreen extends StatelessWidget {
const ProfilePreviewScreen({super.key});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
color: AppColors.primaryBlue,
child: Assets.images.bgPattern.svg(
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
),
Scaffold(
backgroundColor: Colors.transparent,
appBar: KwAppBar(
contentColor: AppColors.grayWhite,
titleText: 'Profile',
centerTitle: false,
backgroundColor: Colors.transparent,
),
body: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
color: AppColors.bgProfileCard,
borderRadius: BorderRadius.circular(12),
),
child: BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildUserPhoto(state.profileImageUrl, '${state.firstName} ${state.lastName}'),
const Gap(16),
_buildUserInfo(context, state),
const Gap(24),
KwButton.accent(
label: 'Edit Profile',
onPressed: () {
context.router.push(const PersonalInfoRoute());
}),
const Gap(12),
KwButton.outlinedPrimary(
label: 'Log Out',
onPressed: ()async {
await FirebaseAuth.instance.signOut();
context.router.replace(const SplashRoute());
}).copyWith(color: AppColors.tintDarkRed),
const Gap(12),
],
);
},
),
),
),
),
],
);
}
Container _buildUserPhoto(String? imageUrl, String? userName) {
return Container(
width: 174,
height: 174,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.darkBgActiveButtonState,
),
child: imageUrl == null || imageUrl.isEmpty
? Center(
child: Text(
getInitials(userName),
style: AppTextStyles.headingH1.copyWith(
color: Colors.white,
),
),
)
: ClipOval(
child: Image.network(
imageUrl,
fit: BoxFit.cover,
width: 174,
height: 174,
),
),
);
}
Column _buildUserInfo(BuildContext context, PersonalInfoState state) {
return Column(
children: [
Text(
'${state.firstName} ${state.lastName}',
style: AppTextStyles.headingH3.copyWith(color: Colors.white),
textAlign: TextAlign.center,
),
if (state.email.isNotEmpty) ...[
const Gap(8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Assets.images.icons.userProfile.sms.svg(
colorFilter: const ColorFilter.mode(
AppColors.tintDropDownButton,
BlendMode.srcIn,
),
),
const Gap(4),
Text(
state.email,
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.tintDropDownButton),
)
],
),
],
if (state.phoneNumber.isNotEmpty) ...[
const Gap(8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Assets.images.icons.userProfile.call.svg(
colorFilter: const ColorFilter.mode(
AppColors.tintDropDownButton,
BlendMode.srcIn,
),
),
const Gap(4),
Text(
state.phoneNumber,
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.tintDropDownButton),
)
],
),
]
],
);
}
String getInitials(String? name) {
if (name == null || name.isEmpty) return '';
if(name.trim().isEmpty) {
return '';
}
List<String> nameParts = name.split(' ');
if(nameParts.first.isEmpty) {
return '';
}
if (nameParts.length == 1) {
return nameParts[0].substring(0, 1).toUpperCase();
}
return (nameParts[0][0] + nameParts[1][0]).toUpperCase();
}
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/widgets/profile_icon.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_phone_input.dart';
import 'package:krow/features/profile/domain/bloc/personal_info_bloc.dart';
class PersonalInfoForm extends StatefulWidget {
const PersonalInfoForm({
super.key,
});
@override
State<PersonalInfoForm> createState() => _PersonalInfoFormState();
}
class _PersonalInfoFormState extends State<PersonalInfoForm> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _phoneController = TextEditingController();
// final _emailController = TextEditingController();
late final _bloc = context.read<PersonalInfoBloc>();
void _syncControllersWithState(PersonalInfoState state) {
_firstNameController.text = state.firstName;
_lastNameController.text = state.lastName;
// _emailController.text = state.email;
_phoneController.text = state.phoneNumber;
}
@override
void initState() {
super.initState();
_syncControllersWithState(_bloc.state);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(16),
BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
buildWhen: (previous, current) =>
previous.profileImageUrl != current.profileImageUrl,
builder: (context, state) {
return ProfileIcon(
diameter: 96,
onChange: (imagePath) {
_bloc.add(UpdateProfileImage(imagePath));
},
imagePath: state.profileImagePath,
imageUrl: state.profileImageUrl,
imageQuality: 0,
);
},
),
const Gap(24),
BlocConsumer<PersonalInfoBloc, PersonalInfoState>(
buildWhen: (previous, current) => previous.status != current.status,
listenWhen: (previous, current) =>
previous.isUpdateReceived != current.isUpdateReceived,
listener: (_, state) {
if (!state.isUpdateReceived) return;
_syncControllersWithState(state);
},
builder: (context, state) {
final bool isValidationFailed = state.status == StateStatus.error;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Name', style: AppTextStyles.headingH3),
const Gap(12),
KwTextInput(
title: 'First Name',
hintText: 'Enter your first name',
controller: _firstNameController,
keyboardType: TextInputType.name,
onChanged: (firstName) {
_bloc.add(UpdateFirstName(firstName));
},
),
const Gap(8),
KwTextInput(
title: 'Last Name',
hintText: 'Enter your last name',
controller: _lastNameController,
keyboardType: TextInputType.name,
onChanged: (lastName) {
_bloc.add(UpdateLastName(lastName));
},
),
// const Gap(8),
// KwTextInput(
// title: 'Middle Name (optional)',
// hintText: 'Enter your middle name',
// controller: _middleNameController,
// keyboardType: TextInputType.name,
// onChanged: (middleName) {
// _bloc.add(UpdateMiddleName(middleName));
// },
// ),
const Gap(24),
const Text('Phone Number', style: AppTextStyles.headingH3),
const Gap(12),
KwPhoneInput(
controller: _phoneController,
onChanged: (phoneNumber) {
_bloc.add(UpdatePhoneNumber(phoneNumber));
},
),
// const Gap(24),
// const Text('Email', style: AppTextStyles.headingH3),
// const Gap(12),
// KwTextInput(
// hintText: 'email@website.com',
// controller: _emailController,
// keyboardType: TextInputType.emailAddress,
// showError: isValidationFailed &&
// state.emailValidationError.isNotEmpty,
// helperText: state.emailValidationError,
// onChanged: (email) {
// _bloc.add(UpdateEmail(email));
// },
// ),
],
);
},
),
const Gap(40),
],
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/features/profile/domain/bloc/personal_info_bloc.dart';
@RoutePage()
class ProfileFlowScreen extends StatelessWidget {
const ProfileFlowScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (BuildContext context) =>
PersonalInfoBloc()..add(InitializeProfileInfoEvent()),
child: const AutoRouter(),
);
}
}