feat: legacy mobile apps created

This commit is contained in:
Achintha Isuru
2025-12-02 23:51:04 -05:00
parent 850441ca64
commit 8e7753b324
1519 changed files with 0 additions and 16 deletions

View File

@@ -0,0 +1,35 @@
import 'package:krow/core/application/clients/api/gql.dart';
const String getStaffPersonalInfoSchema = '''
query GetPersonalInfo {
me {
id
first_name
last_name
middle_name
email
phone
status
avatar
}
}
''';
const String updateStaffMutationSchema = '''
$staffFragment
mutation UpdateStaffPersonalInfo(\$input: UpdateStaffPersonalInfoInput!) {
update_staff_personal_info(input: \$input) {
...StaffFields
}
}
''';
const String updateStaffMutationWithAvatarSchema = '''
$staffFragment
mutation UpdateStaffPersonalInfo(\$input: UpdateStaffPersonalInfoInput!, \$file: Upload!) {
update_staff_personal_info(input: \$input) {
...StaffFields
}
upload_staff_avatar(file: \$file)
}
''';

View File

@@ -0,0 +1,85 @@
import 'dart:developer';
import 'dart:io';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:injectable/injectable.dart';
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/core/data/models/staff/staff.dart';
import 'package:krow/features/profile/personal_info/data/gql_schemas.dart';
@singleton
class StaffPersonalInfoApiProvider {
StaffPersonalInfoApiProvider(this._client);
final ApiClient _client;
Stream<Staff> getMeWithCache() async* {
await for (var response in _client.queryWithCache(
schema: getStaffPersonalInfoSchema,
)) {
if (response == null || response.data == null) continue;
if (response.hasException) {
throw Exception(response.exception.toString());
}
try {
final staffData = response.data?['me'] as Map<String, dynamic>? ?? {};
if (staffData.isEmpty) continue;
yield Staff.fromJson(staffData);
} catch (except) {
log(
'Exception in StaffApi on getMeWithCache()',
error: except,
);
continue;
}
}
}
Future<Staff> updateStaffPersonalInfo({
required String firstName,
required String? middleName,
required String lastName,
required String email,
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,
'middle_name': middleName,
'last_name': lastName,
'email': email,
},
if (multipartFile != null) 'file': multipartFile,
},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return Staff.fromJson(
(result.data as Map<String, dynamic>)['update_staff_personal_info'],
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:krow/core/data/models/staff/staff.dart';
abstract class StaffPersonalInfoRepository {
Stream<Staff> getPersonalInfo();
Future<Staff> updatePersonalInfo({
required String firstName,
required String? middleName,
required String lastName,
required String email,
String? avatarPath,
});
}

View File

@@ -0,0 +1,217 @@
import 'dart:developer';
import 'package:easy_localization/easy_localization.dart';
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/models/staff/staff.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/email_verification/data/email_verification_service.dart';
import 'package:krow/features/profile/personal_info/data/staff_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<UpdateMiddleName>(_onUpdateMiddleName);
on<UpdateEmail>(_onUpdateEmail);
on<UpdatePhoneNumber>(_onUpdatePhoneNumber);
on<UpdateEmailVerificationStatus>(_onUpdateEmailVerification);
on<SaveProfileChanges>(_onSaveProfileChanges);
}
Future<void> _onInitializeProfileInfoEvent(
InitializeProfileInfoEvent event,
Emitter<PersonalInfoState> emit,
) async {
emit(
state.copyWith(
isInEditMode: event.isInEditMode,
status: event.isInEditMode ? StateStatus.loading : StateStatus.idle,
),
);
if (!state.isInEditMode) return;
await for (final currentStaffData
in getIt<StaffPersonalInfoRepository>().getPersonalInfo()) {
try {
emit(
state.copyWith(
firstName: currentStaffData.firstName,
lastName: currentStaffData.lastName,
middleName: currentStaffData.middleName,
email: currentStaffData.email,
phoneNumber: currentStaffData.phone,
profileImageUrl: currentStaffData.avatar,
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,
validationErrors: Map.from(state.validationErrors)
..remove(ProfileRequiredField.firstName),
),
);
}
void _onUpdateLastName(
UpdateLastName event,
Emitter<PersonalInfoState> emit,
) {
emit(
state.copyWith(
lastName: event.lastName,
validationErrors: Map.from(state.validationErrors)
..remove(ProfileRequiredField.lastName),
),
);
}
void _onUpdateMiddleName(
UpdateMiddleName event,
Emitter<PersonalInfoState> emit,
) {
emit(state.copyWith(middleName: event.middleName));
}
void _onUpdateEmail(
UpdateEmail event,
Emitter<PersonalInfoState> emit,
) {
emit(
state.copyWith(
email: event.email,
validationErrors: Map.from(state.validationErrors)
..remove(ProfileRequiredField.email),
),
);
}
void _onUpdatePhoneNumber(
UpdatePhoneNumber event,
Emitter<PersonalInfoState> emit,
) {
emit(state.copyWith(phoneNumber: event.phoneNumber));
}
void _onUpdateEmailVerification(
UpdateEmailVerificationStatus event,
Emitter<PersonalInfoState> emit,
) {
emit(
state.copyWith(
isEmailVerified: event.isEmailVerified,
shouldRouteToVerification: false,
),
);
if (state.isEmailVerified) {
add(const SaveProfileChanges(shouldSkipEmailVerification: true));
}
}
Future<void> _onSaveProfileChanges(
SaveProfileChanges event,
Emitter<PersonalInfoState> emit,
) async {
final errorsMap = state.invalidate();
if (errorsMap.isNotEmpty) {
emit(
state.copyWith(
status: StateStatus.error,
validationErrors: errorsMap,
),
);
return;
}
emit(state.copyWith(status: StateStatus.loading, validationErrors: {}));
if (!event.shouldSkipEmailVerification) {
log('Verifying email');
bool isEmailVerified = getIt<EmailVerificationService>()
.checkEmailForVerification(email: state.email);
log('Email verified: $isEmailVerified');
emit(
state.copyWith(
isEmailVerified: isEmailVerified,
shouldRouteToVerification: !isEmailVerified,
),
);
log('Should route to Verification ${state.shouldRouteToVerification}');
if (!isEmailVerified) {
emit(
state.copyWith(
status: StateStatus.idle,
shouldRouteToVerification: false,
),
);
return;
}
log('Continuing with profile saving');
}
Staff? response;
try {
response = await getIt<StaffPersonalInfoRepository>().updatePersonalInfo(
firstName: state.firstName,
middleName: state.middleName,
lastName: state.lastName,
email: state.email,
avatarPath: state.profileImagePath,
);
} catch (except) {
log(
'Error in PersonalInfoBloc. Event SaveProfileChanges',
error: except,
);
} finally {
emit(
state.copyWith(
status: response != null ? StateStatus.success : StateStatus.idle,
),
);
}
}
}

View File

@@ -0,0 +1,60 @@
part of 'personal_info_bloc.dart';
@immutable
sealed class PersonalInfoEvent {
const PersonalInfoEvent();
}
class InitializeProfileInfoEvent extends PersonalInfoEvent {
const InitializeProfileInfoEvent(this.isInEditMode);
final bool isInEditMode;
}
class UpdateProfileImage extends PersonalInfoEvent {
const UpdateProfileImage(this.imagePath);
final String imagePath;
}
class UpdateFirstName extends PersonalInfoEvent {
const UpdateFirstName(this.firstName);
final String firstName;
}
class UpdateLastName extends PersonalInfoEvent {
const UpdateLastName(this.lastName);
final String lastName;
}
class UpdateMiddleName extends PersonalInfoEvent {
const UpdateMiddleName(this.middleName);
final String middleName;
}
class UpdateEmail extends PersonalInfoEvent {
const UpdateEmail(this.email);
final String email;
}
class UpdatePhoneNumber extends PersonalInfoEvent {
const UpdatePhoneNumber(this.phoneNumber);
final String phoneNumber;
}
class UpdateEmailVerificationStatus extends PersonalInfoEvent {
const UpdateEmailVerificationStatus({required this.isEmailVerified});
final bool isEmailVerified;
}
class SaveProfileChanges extends PersonalInfoEvent {
const SaveProfileChanges({this.shouldSkipEmailVerification = false});
final bool shouldSkipEmailVerification;
}

View File

@@ -0,0 +1,81 @@
part of 'personal_info_bloc.dart';
@immutable
class PersonalInfoState {
const PersonalInfoState({
this.firstName = '',
this.lastName = '',
this.email = '',
this.phoneNumber = '',
this.middleName,
this.profileImageUrl,
this.profileImagePath,
this.isInEditMode = true,
this.isUpdateReceived = false,
this.status = StateStatus.idle,
this.validationErrors = const <ProfileRequiredField, String>{},
this.isEmailVerified = false,
this.shouldRouteToVerification = false,
});
final String firstName;
final String lastName;
final String email;
final String phoneNumber;
final String? middleName;
final String? profileImageUrl;
final String? profileImagePath;
final bool isInEditMode;
final bool isUpdateReceived;
final StateStatus status;
final Map<ProfileRequiredField, String> validationErrors;
final bool isEmailVerified;
final bool shouldRouteToVerification;
PersonalInfoState copyWith({
String? firstName,
String? lastName,
String? email,
String? phoneNumber,
String? middleName,
String? profileImageUrl,
String? profileImagePath,
bool? isInEditMode,
bool? isUpdateReceived,
StateStatus? status,
Map<ProfileRequiredField, String>? validationErrors,
bool? isEmailVerified,
bool? shouldRouteToVerification,
}) {
return PersonalInfoState(
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
email: email ?? this.email,
phoneNumber: phoneNumber ?? this.phoneNumber,
middleName: middleName ?? this.middleName,
profileImagePath: profileImagePath ?? this.profileImagePath,
profileImageUrl: profileImageUrl ?? this.profileImageUrl,
isInEditMode: isInEditMode ?? this.isInEditMode,
isUpdateReceived: isUpdateReceived ?? this.isUpdateReceived,
status: status ?? this.status,
validationErrors: validationErrors ?? this.validationErrors,
isEmailVerified: isEmailVerified ?? this.isEmailVerified,
shouldRouteToVerification:
shouldRouteToVerification ?? this.shouldRouteToVerification,
);
}
Map<ProfileRequiredField, String> invalidate() {
final emailError = EmailValidator.validate(email, isRequired: true);
return {
if (firstName.isEmpty) ProfileRequiredField.firstName: 'required_to_fill'.tr(),
if (lastName.isEmpty) ProfileRequiredField.lastName: 'required_to_fill'.tr(),
if (emailError != null) ProfileRequiredField.email: emailError,
if (profileImagePath == null && profileImageUrl == null)
ProfileRequiredField.avatar: 'required'.tr(),
};
}
}
enum ProfileRequiredField { firstName, lastName, email, avatar }

View File

@@ -0,0 +1,42 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:krow/core/data/models/staff/staff.dart';
import 'package:krow/features/profile/personal_info/data/staff_api_source.dart';
import 'package:krow/features/profile/personal_info/data/staff_repository.dart';
@Singleton(as: StaffPersonalInfoRepository)
class StaffPersonalInfoRepositoryImpl implements StaffPersonalInfoRepository {
StaffPersonalInfoRepositoryImpl({
required StaffPersonalInfoApiProvider staffApi,
}) : _staffApi = staffApi;
final StaffPersonalInfoApiProvider _staffApi;
@override
Stream<Staff> getPersonalInfo() {
return _staffApi.getMeWithCache();
}
@override
Future<Staff> updatePersonalInfo({
required String firstName,
required String? middleName,
required String lastName,
required String email,
String? avatarPath,
}) {
try {
return _staffApi.updateStaffPersonalInfo(
firstName: firstName,
middleName: middleName,
lastName: lastName,
email: email,
avatarPath: avatarPath,
);
} catch (exception) {
log((exception as Error).stackTrace.toString());
rethrow;
}
}
}

View File

@@ -0,0 +1,118 @@
import 'dart:developer';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/routing/routes.gr.dart';
import 'package:krow/core/data/enums/state_status.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/personal_info/domain/bloc/personal_info_bloc.dart';
import 'package:krow/features/profile/personal_info/presentation/widgets/personal_info_form.dart';
@RoutePage()
class PersonalInfoScreen extends StatelessWidget implements AutoRouteWrapper {
const PersonalInfoScreen({
super.key,
this.isInEditMode = false,
});
final bool isInEditMode;
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) =>
PersonalInfoBloc()..add(InitializeProfileInfoEvent(isInEditMode)),
child: this,
);
}
bool _handleBuildWhen(
PersonalInfoState previous,
PersonalInfoState current,
) {
return previous.status != current.status;
}
bool _handleListenWhen(
PersonalInfoState previous,
PersonalInfoState current,
) {
return previous.status != current.status ||
previous.shouldRouteToVerification != current.shouldRouteToVerification;
}
Future<void> _handleListen(
BuildContext context,
PersonalInfoState state,
) async {
if (state.shouldRouteToVerification) {
final isEmailVerified = await context.pushRoute<bool>(
EmailVerificationRoute(
verifiedEmail: state.email,
userPhone: state.phoneNumber,
),
);
if (!context.mounted) return;
context.read<PersonalInfoBloc>().add(
UpdateEmailVerificationStatus(
isEmailVerified: isEmailVerified ?? false,
),
);
return;
}
if (state.status == StateStatus.success && context.mounted) {
if (isInEditMode) {
context.maybePop();
} else {
context.pushRoute(
EmergencyContactsRoute(isInEditMode: false),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'Personal Information'.tr(),
showNotification: isInEditMode,
),
body: SingleChildScrollView(
primary: false,
padding: const EdgeInsets.all(16),
child: PersonalInfoForm(isInEditMode: isInEditMode),
),
bottomNavigationBar: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: BlocConsumer<PersonalInfoBloc, PersonalInfoState>(
buildWhen: _handleBuildWhen,
listenWhen: _handleListenWhen,
listener: _handleListen,
builder: (context, state) {
return KwLoadingOverlay(
shouldShowLoading: state.status == StateStatus.loading,
child: KwButton.primary(
label: isInEditMode ? 'save_changes'.tr() : 'save_and_continue'.tr(),
onPressed: () {
context
.read<PersonalInfoBloc>()
.add(const SaveProfileChanges());
},
),
);
},
),
),
),
);
}
}

View File

@@ -0,0 +1,174 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.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/profile_icon.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
import 'package:krow/features/profile/personal_info/domain/bloc/personal_info_bloc.dart';
class PersonalInfoForm extends StatefulWidget {
const PersonalInfoForm({
super.key,
required this.isInEditMode,
});
final bool isInEditMode;
@override
State<PersonalInfoForm> createState() => _PersonalInfoFormState();
}
class _PersonalInfoFormState extends State<PersonalInfoForm> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _middleNameController = TextEditingController();
final _emailController = TextEditingController();
late final _bloc = context.read<PersonalInfoBloc>();
void _syncControllersWithState(PersonalInfoState state) {
_firstNameController.text = state.firstName;
_lastNameController.text = state.lastName;
_middleNameController.text = state.middleName ?? '';
_emailController.text = state.email;
}
@override
void initState() {
super.initState();
_syncControllersWithState(_bloc.state);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!widget.isInEditMode) ...[
const Gap(4),
Text(
'lets_get_started'.tr(),
style: AppTextStyles.headingH1,
textAlign: TextAlign.start,
),
const Gap(8),
Text(
'tell_us_about_yourself'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: AppColors.blackGray,
),
textAlign: TextAlign.start,
),
const Gap(24),
],
BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
buildWhen: (previous, current) =>
previous.profileImageUrl != current.profileImageUrl ||
previous.validationErrors != current.validationErrors,
builder: (context, state) {
return ProfileIcon(
onChange: (imagePath) {
_bloc.add(UpdateProfileImage(imagePath));
},
imagePath: state.profileImagePath,
imageUrl: state.profileImageUrl,
showError: state.validationErrors
.containsKey(ProfileRequiredField.avatar),
);
},
),
const Gap(8),
BlocConsumer<PersonalInfoBloc, PersonalInfoState>(
buildWhen: (previous, current) {
return previous.status != current.status ||
previous.validationErrors != current.validationErrors;
},
listenWhen: (previous, current) =>
previous.isUpdateReceived != current.isUpdateReceived,
listener: (_, state) {
if (!state.isUpdateReceived) return;
_syncControllersWithState(state);
},
builder: (context, state) {
return Column(
children: [
KwTextInput(
title: 'first_name'.tr(),
hintText: 'enter_first_name'.tr(),
controller: _firstNameController,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.sentences,
showError: state.validationErrors
.containsKey(ProfileRequiredField.firstName),
helperText:
state.validationErrors[ProfileRequiredField.firstName],
onChanged: (firstName) {
_bloc.add(UpdateFirstName(firstName));
},
),
const Gap(8),
KwTextInput(
title: 'last_name'.tr(),
hintText: 'enter_last_name'.tr(),
controller: _lastNameController,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.sentences,
showError: state.validationErrors
.containsKey(ProfileRequiredField.lastName),
helperText:
state.validationErrors[ProfileRequiredField.lastName],
onChanged: (lastName) {
_bloc.add(UpdateLastName(lastName));
},
),
const Gap(8),
KwTextInput(
title: 'middle_name_optional'.tr(),
hintText: 'enter_middle_name'.tr(),
controller: _middleNameController,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.sentences,
onChanged: (middleName) {
_bloc.add(UpdateMiddleName(middleName));
},
),
const Gap(8),
KwTextInput(
title: 'email'.tr(),
hintText: 'email@website.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
showError: state.validationErrors
.containsKey(ProfileRequiredField.email),
helperText:
state.validationErrors[ProfileRequiredField.email],
textInputAction: TextInputAction.done,
onChanged: (email) {
_bloc.add(UpdateEmail(email));
},
),
// It is nor clear if we will need it in future.
// if (widget.isInEditMode) ...[
// const Gap(8),
// KwPhoneInput(
// label: 'Phone',
// onChanged: (phoneNumber) {
// _bloc.add(UpdatePhoneNumber(phoneNumber));
// },
// ),
// ]
],
);
},
),
const Gap(40),
],
);
}
}