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,41 @@
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/core/data/models/staff/full_address_model.dart';
import 'package:krow/features/profile/address/data/gql.dart';
@injectable
class AddressApiProvider {
final ApiClient _apiClient;
AddressApiProvider(this._apiClient);
Stream<FullAddress?> getStaffAddress() async* {
await for (var response
in _apiClient.queryWithCache(schema: staffAddress)) {
if (response == null) {
continue;
}
if (response.hasException) {
throw Exception(response.exception.toString());
}
final address =
FullAddress.fromJson(response.data?['me']?['full_address'] ?? {});
yield address;
}
}
Future<void> putAddress(FullAddress address) async {
final Map<String, dynamic> variables = {
'input': address.toJson(),
};
var result =
await _apiClient.mutate(schema: saveFullAddress, body: variables);
if (result.hasException) {
throw Exception(result.exception.toString());
}
}
}

View File

@@ -0,0 +1,7 @@
import 'package:krow/core/data/models/staff/full_address_model.dart';
abstract class AddressRepository {
Stream<FullAddress?> getStaffAddress();
Future<void> putAddress(FullAddress address);
}

View File

@@ -0,0 +1,26 @@
const String staffAddress = r'''
query staffAddress {
me {
id
full_address {
street_number
zip_code
latitude
longitude
formatted_address
street
region
city
country
}
}
}
''';
const String saveFullAddress = r'''
mutation saveFullAddress($input: AddressInput!) {
update_staff_address(input: $input) {
}
}
''';

View File

@@ -0,0 +1,22 @@
import 'package:injectable/injectable.dart';
import 'package:krow/core/data/models/staff/full_address_model.dart';
import 'package:krow/features/profile/address/data/address_api_provider.dart';
import 'package:krow/features/profile/address/data/address_repository.dart';
@Singleton(as: AddressRepository)
class AddressRepositoryImpl implements AddressRepository {
final AddressApiProvider _apiProvider;
AddressRepositoryImpl({required AddressApiProvider apiProvider})
: _apiProvider = apiProvider;
@override
Stream<FullAddress?> getStaffAddress() {
return _apiProvider.getStaffAddress();
}
@override
Future<void> putAddress(FullAddress address) {
return _apiProvider.putAddress(address);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/models/staff/full_address_model.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/address/data/address_repository.dart';
import 'package:krow/features/profile/address/domain/google_places_service.dart';
part 'address_event.dart';
part 'address_state.dart';
class AddressBloc extends Bloc<AddressEvent, AddressState> {
AddressBloc() : super(const AddressState()) {
on<InitializeAddressEvent>(_onInitialize);
on<SubmitAddressEvent>(_onSubmit);
on<AddressQueryChangedEvent>(_onQueryChanged);
on<AddressSelectEvent>(_onSelect);
}
void _onInitialize(
InitializeAddressEvent event, Emitter<AddressState> emit) async {
emit(state.copyWith(status: StateStatus.loading));
await for (var address in getIt<AddressRepository>().getStaffAddress()) {
emit(state.copyWith(
fullAddress: address,
status: StateStatus.idle,
));
}
}
void _onQueryChanged(
AddressQueryChangedEvent event, Emitter<AddressState> emit) async {
try {
final googlePlacesService = GooglePlacesService();
final suggestions =
await googlePlacesService.fetchSuggestions(event.query);
emit(state.copyWith(suggestions: suggestions));
} catch (e) {
if (kDebugMode) print(e);
}
}
void _onSelect(AddressSelectEvent event, Emitter<AddressState> emit) async {
final googlePlacesService = GooglePlacesService();
final fullAddress =
await googlePlacesService.getPlaceDetails(event.place.placeId);
FullAddress address = FullAddress.fromGoogle(fullAddress);
emit(state.copyWith(suggestions: [], fullAddress: address));
}
void _onSubmit(SubmitAddressEvent event, Emitter<AddressState> emit) async {
emit(state.copyWith(status: StateStatus.loading));
try {
await getIt<AddressRepository>().putAddress(state.fullAddress!);
emit(state.copyWith(status: StateStatus.success));
} catch (e) {
emit(state.copyWith(status: StateStatus.error));
}
}
}

View File

@@ -0,0 +1,24 @@
part of 'address_bloc.dart';
@immutable
sealed class AddressEvent {}
class InitializeAddressEvent extends AddressEvent {
InitializeAddressEvent();
}
class AddressQueryChangedEvent extends AddressEvent {
final String query;
AddressQueryChangedEvent(this.query);
}
class SubmitAddressEvent extends AddressEvent {
SubmitAddressEvent();
}
class AddressSelectEvent extends AddressEvent {
final MapPlace place;
AddressSelectEvent(this.place);
}

View File

@@ -0,0 +1,26 @@
part of 'address_bloc.dart';
@immutable
class AddressState {
final StateStatus status;
final FullAddress? fullAddress;
final List<MapPlace> suggestions;
const AddressState({
this.status = StateStatus.idle,
this.fullAddress,
this.suggestions = const [],
});
AddressState copyWith({
StateStatus? status,
FullAddress? fullAddress,
List<MapPlace>? suggestions,
}) {
return AddressState(
status: status ?? this.status,
suggestions: suggestions ?? this.suggestions,
fullAddress: fullAddress ?? this.fullAddress,
);
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:injectable/injectable.dart';
@singleton
class GooglePlacesService {
Future<List<MapPlace>> fetchSuggestions(String query) async {
final String apiKey = dotenv.env['GOOGLE_MAP']!;
const String baseUrl =
'https://maps.googleapis.com/maps/api/place/autocomplete/json';
final Uri uri =
Uri.parse('$baseUrl?input=$query&key=$apiKey&types=geocode');
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final List<dynamic> predictions = data['predictions'];
return predictions.map((prediction) {
return MapPlace.fromJson(prediction);
}).toList();
} else {
throw Exception('Failed to fetch place suggestions');
}
}
Future<Map<String, dynamic>> getPlaceDetails(String placeId) async {
final String apiKey = dotenv.env['GOOGLE_MAP']!;
final String url =
'https://maps.googleapis.com/maps/api/place/details/json?place_id=$placeId&key=$apiKey';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final result = data['result'];
final location = result['geometry']['location'];
Map<String, dynamic> addressDetails = {
'lat': location['lat'], // Latitude
'lng': location['lng'], // Longitude
'formatted_address': result['formatted_address'], // Full Address
};
for (var component in result['address_components']) {
List types = component['types'];
if (types.contains('street_number')) {
addressDetails['street_number'] = component['long_name'];
}
if (types.contains('route')) {
addressDetails['street'] = component['long_name'];
}
if (types.contains('locality')) {
addressDetails['city'] = component['long_name'];
}
if (types.contains('administrative_area_level_1')) {
addressDetails['state'] = component['long_name'];
}
if (types.contains('country')) {
addressDetails['country'] = component['long_name'];
}
if (types.contains('postal_code')) {
addressDetails['postal_code'] = component['long_name'];
}
}
return addressDetails;
} else {
throw Exception('Failed to fetch place details');
}
}
}
class MapPlace {
final String description;
final String placeId;
MapPlace({required this.description, required this.placeId});
toJson() {
return {
'description': description,
'place_id': placeId,
};
}
factory MapPlace.fromJson(Map<String, dynamic> json) {
return MapPlace(
description: json['description'],
placeId: json['place_id'],
);
}
}

View File

@@ -0,0 +1,150 @@
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: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/styles/kw_box_decorations.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/scroll_layout_helper.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_suggestion_input.dart';
import 'package:krow/features/profile/address/domain/bloc/address_bloc.dart';
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
@RoutePage()
class AddressScreen extends StatefulWidget implements AutoRouteWrapper {
final bool isInEditMode;
const AddressScreen({super.key, this.isInEditMode = true});
@override
State<AddressScreen> createState() => _AddressScreenState();
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) => AddressBloc()..add(InitializeAddressEvent()),
child: this,
);
}
}
class _AddressScreenState extends State<AddressScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
showNotification: widget.isInEditMode,
titleText: 'location_and_availability'.tr(),
),
body: BlocConsumer<AddressBloc, AddressState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == StateStatus.success) {
if (widget.isInEditMode) {
Navigator.pop(context);
} else {
context.router.push(
WorkingAreaRoute(),
);
}
}
},
builder: (context, state) {
return ModalProgressHUD(
inAsyncCall: state.status == StateStatus.loading,
child: ScrollLayoutHelper(
padding: const EdgeInsets.all(16),
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!widget.isInEditMode) ...[
const Gap(4),
Text(
'what_is_your_address'.tr(),
style: AppTextStyles.headingH1,
),
Text(
'let_us_know_your_home_base'.tr(),
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
),
const Gap(24),
]
else
const Gap(8),
KwSuggestionInput(
title: 'address'.tr(),
hintText: 'select_address'.tr(),
horizontalPadding: 16,
items: state.suggestions,
onQueryChanged: (query) {
context
.read<AddressBloc>()
.add(AddressQueryChangedEvent(query));
},
itemToStringBuilder: (item) => item.description,
onSelected: (item) {
context.read<AddressBloc>().add(AddressSelectEvent(item));
},
),
const Gap(8),
Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._textBlock('country'.tr(), state.fullAddress?.country),
..._textBlock('state'.tr(), state.fullAddress?.region),
..._textBlock('city'.tr(), state.fullAddress?.city),
..._textBlock('apt_suite_building'.tr(),
state.fullAddress?.streetNumber),
..._textBlock(
'street_address'.tr(), state.fullAddress?.street),
..._textBlock('zip_code'.tr(), state.fullAddress?.zipCode,
hasNext: false),
]),
)
],
),
lowerWidget: KwButton.primary(
disabled: state.fullAddress == null,
label: widget.isInEditMode
? 'save_changes'.tr()
: 'save_and_continue'.tr(),
onPressed: () {
if (widget.isInEditMode) {
context.read<AddressBloc>().add(SubmitAddressEvent());
} else {
context.router.push(WorkingAreaRoute(isInEditMode: false));
}
}),
),
);
},
),
);
}
List<Widget> _textBlock(String key, String? value, {bool hasNext = true}) {
return [
...[
Text(
key,
style: AppTextStyles.captionReg.copyWith(color: AppColors.blackGray),
),
const Gap(8),
Text(
value ?? '',
style: AppTextStyles.bodyMediumMed,
),
hasNext ? const Gap(24) : const Gap(0),
],
];
}
}

View File

@@ -0,0 +1,39 @@
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/core/data/models/staff/bank_acc.dart';
import 'package:krow/features/profile/bank_account/data/gql.dart';
@injectable
class BankAccountApiProvider {
final ApiClient _apiClient;
BankAccountApiProvider(this._apiClient);
Stream<BankAcc?> getStaffBankAcc() async* {
await for (var response
in _apiClient.queryWithCache(schema: staffBankAccount)) {
if (response == null || response.data == null) {
continue;
}
if (response.hasException) {
throw Exception(response.exception.toString());
}
final bankAcc =
BankAcc.fromJson(response.data?['me']?['bank_account'] ?? {});
yield bankAcc;
}
}
Future<void> putBunkAccount(BankAcc bankAcc) async {
final Map<String, dynamic> variables = {
'input': bankAcc.toJson(),
};
var result =
await _apiClient.mutate(schema: updateBankAccount, body: variables);
if (result.hasException) {
throw Exception(result.exception.toString());
}
}
}

View File

@@ -0,0 +1,7 @@
import 'package:krow/core/data/models/staff/bank_acc.dart';
abstract class BankAccountRepository {
Stream<BankAcc?> getStaffBankAcc();
Future<void> putBankAcc(BankAcc address);
}

View File

@@ -0,0 +1,28 @@
const String staffBankAccount = r'''
query staffAddress {
me {
id
bank_account {
id
holder_name
bank_name
number
routing_number
country
state
city
street
building
zip
}
}
}
''';
const String updateBankAccount = r'''
mutation updateBankAccount($input: UpdateStaffBankAccountInput!) {
update_staff_bank_account(input: $input) {
}
}
''';

View File

@@ -0,0 +1,22 @@
import 'package:injectable/injectable.dart';
import 'package:krow/core/data/models/staff/bank_acc.dart';
import 'package:krow/features/profile/bank_account/data/bank_account_api_provider.dart';
import 'package:krow/features/profile/bank_account/data/bank_account_repository.dart';
@Singleton(as: BankAccountRepository)
class BankAccountRepositoryImpl implements BankAccountRepository {
final BankAccountApiProvider _apiProvider;
BankAccountRepositoryImpl({required BankAccountApiProvider apiProvider})
: _apiProvider = apiProvider;
@override
Stream<BankAcc?> getStaffBankAcc() {
return _apiProvider.getStaffBankAcc();
}
@override
Future<void> putBankAcc(BankAcc acc) {
return _apiProvider.putBunkAccount(acc);
}
}

View File

@@ -0,0 +1,32 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/models/staff/bank_acc.dart';
import 'package:krow/features/profile/bank_account/data/bank_account_repository.dart';
part 'bank_account_event.dart';
part 'bank_account_state.dart';
class BankAccountBloc extends Bloc<BankAccountEvent, BankAccountState> {
BankAccountBloc() : super(const BankAccountState()) {
on<BankAccountEventInit>(_onInit);
on<BankAccountEventUpdate>(_onSubmit);
}
FutureOr<void> _onInit(event, emit) async {
await for (var account
in getIt<BankAccountRepository>().getStaffBankAcc()) {
emit(state.copyWith(bankAcc: account));
}
}
FutureOr<void> _onSubmit(BankAccountEventUpdate event, emit) async {
emit(state.copyWith(inLoading: true));
var newBankAcc = BankAcc.fromJson(event.bankAcc);
await getIt<BankAccountRepository>().putBankAcc(newBankAcc);
emit(state.copyWith(success: true, bankAcc: newBankAcc));
}
}

View File

@@ -0,0 +1,12 @@
part of 'bank_account_bloc.dart';
@immutable
sealed class BankAccountEvent {}
class BankAccountEventInit extends BankAccountEvent {}
class BankAccountEventUpdate extends BankAccountEvent {
final Map<String, String> bankAcc;
BankAccountEventUpdate(this.bankAcc);
}

View File

@@ -0,0 +1,77 @@
part of 'bank_account_bloc.dart';
@immutable
class BankAccountState {
final BankAcc? bankAcc;
final bool inLoading;
final bool success;
const BankAccountState(
{this.inLoading = false, this.success = false, this.bankAcc});
BankAccountState copyWith({
BankAcc? bankAcc,
bool? inLoading,
bool? success,
}) {
return BankAccountState(
inLoading: inLoading ?? false,
success: success ?? false,
bankAcc: bankAcc ?? this.bankAcc,
);
}
Map<String, BankAccField> mapFromBankAccount() {
return {
'holder_name': BankAccField(
title: 'account_holder_name'.tr(),
value: bankAcc?.holderName,
),
'bank_name': BankAccField(
title: 'bank_name'.tr(),
value: bankAcc?.bankName,
),
'number': BankAccField(
title: 'account_number'.tr(),
value: bankAcc?.number,
),
'routing_number': BankAccField(
title: 'routing_number_us'.tr(),
value: bankAcc?.routingNumber,
optional: true),
'country': BankAccField(
title: 'country'.tr(),
value: bankAcc?.country,
),
'state': BankAccField(
title: 'state'.tr(),
value: bankAcc?.state,
),
'city': BankAccField(
title: 'city'.tr(),
value: bankAcc?.city,
),
'street': BankAccField(
title: 'street_address'.tr(),
value: bankAcc?.street,
),
'building': BankAccField(
title: 'apt_suite_building'.tr(),
value: bankAcc?.building,
optional: true),
'zip': BankAccField(
title: 'zip_code'.tr(),
value: bankAcc?.zip,
),
};
}
}
class BankAccField {
String title;
String? value;
bool optional;
BankAccField({required this.title, this.value = '', this.optional = false});
}

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/bank_account/domain/bloc/bank_account_bloc.dart';
@RoutePage()
class BankAccountFlowScreen extends StatelessWidget {
const BankAccountFlowScreen({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(providers: [
BlocProvider<BankAccountBloc>(
create: (context) => BankAccountBloc()..add(BankAccountEventInit()),
),
], child: const AutoRouter());
}
}

View File

@@ -0,0 +1,161 @@
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:gap/gap.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.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/scroll_layout_helper.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_input.dart';
import 'package:krow/features/profile/bank_account/domain/bloc/bank_account_bloc.dart';
@RoutePage()
class BankAccountEditScreen extends StatefulWidget {
const BankAccountEditScreen({super.key});
@override
State<BankAccountEditScreen> createState() => _BankAccountEditScreenState();
}
class _BankAccountEditScreenState extends State<BankAccountEditScreen> {
Map<String, BankAccField>? bankAccInfo;
final Map<String, TextEditingController> _controllers = {};
var showInputError = false;
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback((_) {
bankAccInfo = Map.of(
BlocProvider.of<BankAccountBloc>(context).state.mapFromBankAccount());
bankAccInfo?.keys.forEach((key) {
_controllers[key] =
TextEditingController(text: bankAccInfo?[key]?.value);
});
setState(() {});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocConsumer<BankAccountBloc, BankAccountState>(
listener: (context, state) {
if (state.success) {
context.router.maybePop();
}
},
builder: (context, state) {
return Scaffold(
appBar: KwAppBar(
titleText: 'edit_bank_account'.tr(),
showNotification: true,
),
body: ScrollLayoutHelper(
padding: const EdgeInsets.all(16),
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'edit_information_below'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
const Gap(24),
Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCardHeader(context, 'account_details'.tr()),
...(bankAccInfo?.entries
.take(4)
.map((entry) =>
_buildInput(entry.key, entry.value))
.toList() ??
[])
],
),
),
const Gap(12),
Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCardHeader(context, 'billing_address'.tr()),
...(bankAccInfo?.entries
.skip(4)
.map((entry) =>
_buildInput(entry.key, entry.value))
.toList() ??
[])
],
),
),
const Gap(24),
],
),
lowerWidget: KwButton.primary(
label: 'save_changes'.tr(),
onPressed: () {
if (bankAccInfo?.values.any((element) =>
!element.optional &&
(element.value?.isEmpty ?? false)) ??
false) {
setState(() {
showInputError = true;
});
return;
}
var newAcc = bankAccInfo?.map((key, value) => MapEntry(
key,
_controllers[key]?.text ?? '',
));
BlocProvider.of<BankAccountBloc>(context)
.add(BankAccountEventUpdate(newAcc!));
},
),
),
);
},
);
}
Widget _buildCardHeader(BuildContext context, String title) {
return Text(
title,
style: AppTextStyles.bodyMediumMed,
);
}
Widget _buildInput(String key, BankAccField field) {
var hasError = !field.optional &&
(_controllers[key]?.text.isEmpty ?? false) &&
showInputError;
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: KwTextInput(
hintText: '',
title: field.title,
onChanged: (value) {
setState(() {
field.value = value;
});
},
helperText: hasError ? 'field_cant_be_empty'.tr() : null,
showError: hasError,
controller: _controllers[key],
),
);
}
}

View File

@@ -0,0 +1,109 @@
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: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_box_decorations.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/scroll_layout_helper.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/features/profile/bank_account/domain/bloc/bank_account_bloc.dart';
@RoutePage()
class BankAccountScreen extends StatefulWidget implements AutoRouteWrapper {
const BankAccountScreen({super.key});
@override
State<BankAccountScreen> createState() => _BankAccountScreenState();
@override
Widget wrappedRoute(BuildContext context) {
return this;
}
}
class _BankAccountScreenState extends State<BankAccountScreen> {
@override
Widget build(BuildContext context) {
return BlocBuilder<BankAccountBloc, BankAccountState>(
builder: (context, state) {
return Scaffold(
appBar: KwAppBar(
titleText: 'bank_account'.tr(),
showNotification: true,
),
body: ScrollLayoutHelper(
padding: const EdgeInsets.all(16),
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'securely_manage_bank_account'.tr(),
style: AppTextStyles.bodySmallReg
.copyWith(color: AppColors.blackGray),
),
const Gap(24),
Container(
padding: const EdgeInsets.all(12),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCardHeader(context),
...(state
.mapFromBankAccount()
.entries
.map((entry) => infoRow(
entry.value.title, entry.value.value ?? ''))
.expand((e) => e)
.toList())
],
),
),
],
),
lowerWidget: Container(),
),
);
},
);
}
Row _buildCardHeader(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'your_payment_details'.tr(),
style: AppTextStyles.bodyMediumMed,
),
GestureDetector(
onTap: () {
context.router.push(const BankAccountEditRoute());
},
child: Assets.images.icons.edit.svg(height: 16, width: 16))
],
);
}
List<Widget> infoRow(
String title,
String value,
) {
return [
const Gap(24),
Text(
'$title:'.toUpperCase(),
style: AppTextStyles.captionReg.copyWith(color: AppColors.blackGray),
),
const Gap(8),
Text(
value,
style: AppTextStyles.bodyMediumMed,
),
];
}
}

View File

@@ -0,0 +1,92 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/benefits/domain/benefits_repository.dart';
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
import 'package:krow/features/profile/benefits/domain/entities/benefit_record_entity.dart';
@Injectable(as: BenefitsRepository)
class BenefitsRepositoryImpl implements BenefitsRepository {
static final _benefitsMock = [
BenefitEntity(
name: 'Sick Leave',
requirement: 'You need at least 8 hours to request sick leave',
requiredHours: 40,
currentHours: 10,
isClaimed: false,
info: 'Listed certificates are mandatory for employees. If the employee '
'does not have the complete certificates, they cant proceed with '
'their registration.',
history: [
BenefitRecordEntity(
createdAt: DateTime(2024, 6, 14),
status: RecordStatus.submitted,
),
BenefitRecordEntity(
createdAt: DateTime(2023, 6, 5),
status: RecordStatus.submitted,
),
BenefitRecordEntity(
createdAt: DateTime(2019, 6, 4),
status: RecordStatus.submitted,
),
BenefitRecordEntity(
createdAt: DateTime(2018, 6, 1),
status: RecordStatus.submitted,
),
BenefitRecordEntity(
createdAt: DateTime(2017, 6, 24),
status: RecordStatus.submitted,
),
BenefitRecordEntity(
createdAt: DateTime(2016, 6, 15),
status: RecordStatus.submitted,
),
BenefitRecordEntity(
createdAt: DateTime(2015, 6, 6),
status: RecordStatus.submitted,
),
],
),
const BenefitEntity(
name: 'Vacation',
requirement: 'You need 40 hours to claim vacation pay',
requiredHours: 40,
currentHours: 40,
isClaimed: false,
info: 'Listed certificates are mandatory for employees. If the employee '
'does not have the complete certificates, they cant proceed with '
'their registration.',
history: [],
),
const BenefitEntity(
name: 'Holidays',
requirement: 'Pay holidays: Thanksgiving, Christmas, New Year',
requiredHours: 24,
currentHours: 1,
isClaimed: false,
info: 'Listed certificates are mandatory for employees. If the employee '
'does not have the complete certificates, they cant proceed with '
'their registration.',
history: [],
),
];
@override
Future<List<BenefitEntity>> getStaffBenefits() async {
await Future.delayed(const Duration(milliseconds: 500));
return _benefitsMock;
}
@override
Future<BenefitEntity?> requestBenefit({
required BenefitEntity benefit,
}) async {
if (benefit.currentHours != benefit.requiredHours || benefit.isClaimed) {
return null;
}
await Future.delayed(const Duration(seconds: 1));
return benefit.copyWith(isClaimed: true);
}
}

View File

@@ -0,0 +1,7 @@
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
abstract interface class BenefitsRepository {
Future<List<BenefitEntity>> getStaffBenefits();
Future<BenefitEntity?> requestBenefit({required BenefitEntity benefit});
}

View File

@@ -0,0 +1,51 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/benefits/domain/benefits_repository.dart';
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
part 'benefits_event.dart';
part 'benefits_state.dart';
class BenefitsBloc extends Bloc<BenefitsEvent, BenefitsState> {
BenefitsBloc() : super(const BenefitsState()) {
on<InitializeBenefits>((event, emit) async {
emit(state.copyWith(status: StateStatus.loading));
final benefits = await _repository.getStaffBenefits();
emit(state.copyWith(status: StateStatus.idle, benefits: benefits));
});
on<SendBenefitRequest>((event, emit) async {
emit(state.copyWith(status: StateStatus.loading));
final result = await _repository.requestBenefit(benefit: event.benefit);
int index = -1;
List<BenefitEntity>? updatedBenefits;
if (result != null) {
index = state.benefits.indexWhere(
(benefit) => benefit.name == result.name,
);
if (index >= 0) {
updatedBenefits = List.from(state.benefits)..[index] = result;
}
}
emit(
state.copyWith(
benefits: updatedBenefits,
status: StateStatus.idle,
),
);
event.requestCompleter.complete(result ?? event.benefit);
});
}
final BenefitsRepository _repository = getIt<BenefitsRepository>();
}

View File

@@ -0,0 +1,20 @@
part of 'benefits_bloc.dart';
@immutable
sealed class BenefitsEvent {
const BenefitsEvent();
}
class InitializeBenefits extends BenefitsEvent {
const InitializeBenefits();
}
class SendBenefitRequest extends BenefitsEvent {
const SendBenefitRequest({
required this.benefit,
required this.requestCompleter,
});
final BenefitEntity benefit;
final Completer<BenefitEntity> requestCompleter;
}

View File

@@ -0,0 +1,26 @@
part of 'benefits_bloc.dart';
@immutable
class BenefitsState {
const BenefitsState({
this.status = StateStatus.idle,
this.benefits = const [],
this.exception,
});
final StateStatus status;
final List<BenefitEntity> benefits;
final Exception? exception;
BenefitsState copyWith({
StateStatus? status,
List<BenefitEntity>? benefits,
Exception? exception,
}) {
return BenefitsState(
status: status ?? this.status,
benefits: benefits ?? this.benefits,
exception: exception,
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/foundation.dart';
import 'package:krow/features/profile/benefits/domain/entities/benefit_record_entity.dart';
@immutable
class BenefitEntity {
const BenefitEntity({
required this.name,
required this.requirement,
required this.requiredHours,
required this.currentHours,
required this.isClaimed,
required this.info,
required this.history,
});
final String name;
final String requirement;
final int requiredHours;
final int currentHours;
final bool isClaimed;
final String info;
final List<BenefitRecordEntity> history;
double get progress {
final progress = currentHours / requiredHours;
return progress > 1 ? 1 : progress;
}
BenefitEntity copyWith({
String? name,
String? requirement,
int? requiredHours,
int? currentHours,
bool? isClaimed,
String? info,
List<BenefitRecordEntity>? history,
}) {
return BenefitEntity(
name: name ?? this.name,
requirement: requirement ?? this.requirement,
requiredHours: requiredHours ?? this.requiredHours,
currentHours: currentHours ?? this.currentHours,
isClaimed: isClaimed ?? this.isClaimed,
info: info ?? this.info,
history: history ?? this.history,
);
}
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
@immutable
class BenefitRecordEntity {
const BenefitRecordEntity({required this.createdAt, required this.status});
final DateTime createdAt;
final RecordStatus status;
}
enum RecordStatus { pending, submitted }

View File

@@ -0,0 +1,95 @@
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: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/styles/theme.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_loading_overlay.dart';
import 'package:krow/features/profile/benefits/domain/bloc/benefits_bloc.dart';
import 'package:krow/features/profile/benefits/presentation/widgets/benefit_card_widget.dart';
@RoutePage()
class BenefitsScreen extends StatefulWidget implements AutoRouteWrapper {
const BenefitsScreen({super.key});
@override
State<BenefitsScreen> createState() => _BenefitsScreenState();
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider<BenefitsBloc>(
create: (context) => BenefitsBloc()..add(const InitializeBenefits()),
child: this,
);
}
}
class _BenefitsScreenState extends State<BenefitsScreen> {
final OverlayPortalController _controller = OverlayPortalController();
@override
Widget build(BuildContext context) {
return Scaffold(
body: KwLoadingOverlay(
controller: _controller,
child: BlocListener<BenefitsBloc, BenefitsState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == StateStatus.loading) {
_controller.show();
} else {
_controller.hide();
}
},
child: CustomScrollView(
primary: false,
slivers: [
SliverList.list(
children: [
KwAppBar(
titleText: 'your_benefits_overview'.tr(),
showNotification: true,
),
const Gap(16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'manage_and_track_benefits'.tr(),
style: AppTextStyles.bodySmallReg.copyWith(
color: AppColors.blackGray,
),
textAlign: TextAlign.start,
),
),
],
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
sliver: BlocBuilder<BenefitsBloc, BenefitsState>(
buildWhen: (current, previous) =>
current.benefits != previous.benefits,
builder: (context, state) {
return SliverList.separated(
itemCount: state.benefits.length,
separatorBuilder: (context, index) {
return const SizedBox(height: 12);
},
itemBuilder: (context, index) {
return BenefitCardWidget(
benefit: state.benefits[index],
);
},
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,253 @@
import 'dart:async';
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/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/dialogs/kw_dialog.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
import 'package:krow/features/profile/benefits/domain/bloc/benefits_bloc.dart';
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
import 'package:krow/features/profile/benefits/presentation/widgets/benefit_history_widget.dart';
class BenefitCardWidget extends StatefulWidget {
const BenefitCardWidget({super.key, required this.benefit});
final BenefitEntity benefit;
@override
State<BenefitCardWidget> createState() => _BenefitCardWidgetState();
}
class _BenefitCardWidgetState extends State<BenefitCardWidget>
with TickerProviderStateMixin {
late final AnimationController _animationController;
late BenefitEntity _benefit = widget.benefit;
Completer<BenefitEntity>? _requestCompleter;
double _progress = 0;
bool _isReady = false;
@override
void initState() {
_progress = widget.benefit.progress;
_isReady = _progress == 1;
super.initState();
_animationController = AnimationController(vsync: this);
_animationController.animateTo(
_progress,
duration: const Duration(seconds: 1),
curve: Curves.easeOut,
);
}
Future<void> _handleRequestPress() async {
_requestCompleter?.completeError(Exception('previous_aborted'.tr()));
final completer = _requestCompleter = Completer<BenefitEntity>();
this.context.read<BenefitsBloc>().add(
SendBenefitRequest(
benefit: _benefit,
requestCompleter: completer,
),
);
final benefit = await completer.future;
if (!benefit.isClaimed) return;
setState(() {
_progress = 0;
_isReady = false;
_benefit = _benefit.copyWith(currentHours: 0);
});
await _animationController.animateTo(
_progress,
duration: const Duration(seconds: 1),
curve: Curves.easeOut,
);
final context = this.context;
if (!context.mounted) return;
await KwDialog.show(
context: context,
icon: Assets.images.icons.like,
state: KwDialogState.positive,
title: 'request_submitted'.tr(),
message: 'request_submitted_message'.tr(args: [_benefit.name.toLowerCase()]),
primaryButtonLabel: 'back_to_profile'.tr(),
onPrimaryButtonPressed: (dialogContext) {
Navigator.maybePop(dialogContext);
context.maybePop();
},
);
}
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: AppColors.grayPrimaryFrame,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Stack(
alignment: AlignmentDirectional.center,
children: [
AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
return CircularProgressIndicator(
constraints:
BoxConstraints.tight(const Size.square(90)),
strokeWidth: 8,
strokeCap: StrokeCap.round,
backgroundColor: AppColors.bgColorLight,
color: _isReady
? AppColors.statusSuccess
: AppColors.primaryBlue,
value: _animationController.value,
);
},
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
RichText(
text: TextSpan(
text: '${_benefit.currentHours}/',
style: AppTextStyles.headingH3,
children: [
TextSpan(
text: '${_benefit.requiredHours}',
style: AppTextStyles.headingH3.copyWith(
color: _isReady
? AppColors.blackBlack
: AppColors.blackCaptionText,
),
),
],
),
),
const SizedBox(height: 4),
Text(
'hours'.tr().toLowerCase(),
style: AppTextStyles.captionReg
.copyWith(color: AppColors.blackCaptionText),
),
],
)
],
),
const SizedBox(width: 24),
Expanded(
child: Stack(
alignment: AlignmentDirectional.centerStart,
clipBehavior: Clip.none,
children: [
const SizedBox(height: 90),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_benefit.name, style: AppTextStyles.headingH3),
const SizedBox(height: 6),
Text(
_benefit.requirement,
style: AppTextStyles.bodySmallReg.copyWith(
color: AppColors.blackGray,
),
),
],
),
Positioned(
top: -4,
right: 0,
child: Assets.images.icons.alertCircle.svg(
height: 16,
width: 16,
colorFilter: const ColorFilter.mode(
AppColors.grayStroke,
BlendMode.srcIn,
),
),
)
],
),
),
],
),
const SizedBox(height: 20),
if (_isReady)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedBox(
decoration: const BoxDecoration(
color: AppColors.tintGreen,
shape: BoxShape.circle,
),
child: SizedBox.square(
dimension: 28,
child: Assets.images.icons.checkCircle.svg(
height: 10,
width: 10,
fit: BoxFit.scaleDown,
colorFilter: const ColorFilter.mode(
AppColors.statusSuccess,
BlendMode.srcIn,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_benefit.info,
style: AppTextStyles.bodyTinyMed.copyWith(
color: AppColors.statusSuccess,
),
),
),
],
),
),
BenefitHistoryWidget(benefit: _benefit),
if (_isReady)
Padding(
padding: const EdgeInsets.only(top: 20),
child: KwButton.primary(
label: '${'request_payment_for'.tr()} ${_benefit.name}',
onPressed: _handleRequestPress,
),
),
],
),
),
);
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
}
}

View File

@@ -0,0 +1,123 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/profile/benefits/domain/entities/benefit_entity.dart';
import 'package:krow/features/profile/benefits/domain/entities/benefit_record_entity.dart';
class BenefitHistoryWidget extends StatelessWidget {
const BenefitHistoryWidget({super.key, required this.benefit});
final BenefitEntity benefit;
@override
Widget build(BuildContext context) {
return ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12),
childrenPadding: const EdgeInsets.symmetric(horizontal: 12),
dense: true,
visualDensity: VisualDensity.compact,
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
backgroundColor: AppColors.graySecondaryFrame,
collapsedBackgroundColor: AppColors.graySecondaryFrame,
iconColor: AppColors.blackBlack,
collapsedIconColor: AppColors.blackBlack,
title: Text(
'${benefit.name} ${'history'.tr()}'.toUpperCase(),
style: AppTextStyles.captionBold,
),
children: [
const Divider(
thickness: 1,
color: AppColors.tintGray,
),
const SizedBox(height: 12),
if (benefit.history.isEmpty)
SizedBox(
height: 80,
child: Center(
child: Text('no_history_yet'.tr()),
),
)
else
SizedBox(
height: 168,
child: RawScrollbar(
padding: EdgeInsets.zero,
thumbVisibility: true,
trackVisibility: true,
thumbColor: AppColors.grayStroke,
trackColor: AppColors.grayTintStroke,
trackRadius: const Radius.circular(8),
radius: const Radius.circular(8),
trackBorderColor: Colors.transparent,
thickness: 5,
minOverscrollLength: 0,
child: ListView.separated(
padding: const EdgeInsetsDirectional.only(end: 10),
itemCount: benefit.history.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
return _HistoryRecordWidget(record: benefit.history[index]);
},
),
),
),
const SizedBox(height: 12),
],
);
}
}
class _HistoryRecordWidget extends StatelessWidget {
const _HistoryRecordWidget({required this.record});
static final _dateFormat = DateFormat('d MMM, yyyy');
final BenefitRecordEntity record;
@override
Widget build(BuildContext context) {
final Color color = switch (record.status) {
RecordStatus.pending => AppColors.primaryBlue,
RecordStatus.submitted => AppColors.statusSuccess,
};
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_dateFormat.format(record.createdAt),
style: AppTextStyles.bodySmallReg.copyWith(
color: AppColors.blackGray,
),
),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(24)),
border: Border.all(color: color),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(
switch (record.status) {
RecordStatus.pending => 'pending'.tr(),
RecordStatus.submitted => 'submitted'.tr(),
},
style: AppTextStyles.bodySmallReg.copyWith(
color: color,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,80 @@
import 'dart:io';
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/pagination_wrapper/pagination_wrapper.dart';
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
import 'certificates_gql.dart';
@injectable
class CertificatesApiProvider {
final ApiClient _apiClient;
CertificatesApiProvider(this._apiClient);
Future<List<CertificateModel>> fetchCertificates() async {
var result = await _apiClient.query(schema: getCertificatesQuery);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return result.data!['certificates'].map<CertificateModel>((e) {
return CertificateModel.fromJson(e);
}).toList();
}
Future<PaginationWrapper<StaffCertificate>> fetchStaffCertificates() async {
var result = await _apiClient.query(schema: getStaffCertificatesQuery);
if (result.hasException) {
throw Exception(result.exception.toString());
}
return PaginationWrapper.fromJson(result.data!['staff_certificates'],
(json) => StaffCertificate.fromJson(json));
}
Future<StaffCertificate> putStaffCertificate(
String certificateId, String imagePath, String certificateDate) async {
var byteData = File(imagePath).readAsBytesSync();
var multipartFile = MultipartFile.fromBytes(
'file',
byteData,
filename: '${DateTime.now().millisecondsSinceEpoch}.jpg',
contentType: MediaType('image', 'jpg'),
);
final Map<String, dynamic> variables = {
'certificate_id': certificateId,
'expiration_date': certificateDate,
'file': multipartFile,
};
var result = await _apiClient.mutate(
schema: putStaffCertificateMutation, body: {'input': variables});
if (result.hasException) {
throw Exception(result.exception.toString());
} else {
return StaffCertificate.fromJson(
result.data!['upload_staff_certificate']);
}
}
Future<void> deleteStaffCertificate(String certificateId) async {
final Map<String, dynamic> variables = {
'id': certificateId,
};
var result = await _apiClient.mutate(
schema: deleteStaffCertificateMutation, body: variables);
if (result.hasException) {
throw Exception(result.exception.toString());
}
}
}

View File

@@ -0,0 +1,56 @@
const String getCertificatesQuery = '''
{
certificates {
id
name
}
}
''';
const String getStaffCertificatesQuery = '''
query fetchStaffCertificates () {
staff_certificates(first: 10) {
pageInfo {
hasNextPage
startCursor
endCursor
}
edges {
cursor
node {
id
expiration_date
status
file
certificate {
id
name
}
}
}
}
}
''';
const String putStaffCertificateMutation = '''
mutation UploadStaffCertificate(\$input: UploadStaffCertificateInput!) {
upload_staff_certificate(input: \$input) {
id
expiration_date
status
file
certificate {
id
name
}
}
}
''';
const String deleteStaffCertificateMutation = '''
mutation DeleteStaffCertificate(\$id: ID!) {
delete_staff_certificate(id: \$id) {
id
}
}
''';

View File

@@ -0,0 +1,24 @@
class CertificateModel {
final String id;
final String name;
CertificateModel({
required this.id,
required this.name,
});
factory CertificateModel.fromJson(Map<String, dynamic> json) {
return CertificateModel(
id: json['id'],
name: json['name'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
};
}
}

View File

@@ -0,0 +1,34 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/certificates/data/certificates_api_provider.dart';
import 'package:krow/features/profile/certificates/domain/certificates_repository.dart';
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
@Injectable(as: CertificatesRepository)
class CertificatesRepositoryImpl extends CertificatesRepository {
final CertificatesApiProvider _certificatesApiProvider;
CertificatesRepositoryImpl(this._certificatesApiProvider);
@override
Future<List<CertificateModel>> getCertificates() async {
return _certificatesApiProvider.fetchCertificates();
}
@override
Future<StaffCertificate> putStaffCertificate(
String certificateId, String imagePath, String certificateDate) {
return _certificatesApiProvider.putStaffCertificate(
certificateId, imagePath, certificateDate);
}
@override
Future<List<StaffCertificate>> getStaffCertificates() async{
return (await _certificatesApiProvider.fetchStaffCertificates()).edges.map((e) => e.node).toList();
}
@override
Future<void> deleteStaffCertificate(String certificateId) {
return _certificatesApiProvider.deleteStaffCertificate(certificateId);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
part 'staff_certificate.g.dart';
enum CertificateStatus {
verified,
pending,
declined,
}
@JsonSerializable(fieldRename: FieldRename.snake)
class StaffCertificate {
final String id;
CertificateModel certificate;
final String expirationDate;
final CertificateStatus status;
final String file;
StaffCertificate(
{required this.id,
required this.certificate,
required this.expirationDate,
required this.status,
required this.file});
factory StaffCertificate.fromJson(Map<String, dynamic> json) {
return _$StaffCertificateFromJson(json);
}
Map<String, dynamic> toJson() => _$StaffCertificateToJson(this);
}

View File

@@ -0,0 +1,91 @@
import 'package:collection/collection.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/features/profile/certificates/domain/certificates_repository.dart';
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
import 'package:krow/features/profile/certificates/domain/bloc/certificates_event.dart';
import 'package:krow/features/profile/certificates/domain/bloc/certificates_state.dart';
class CertificatesBloc extends Bloc<CertificatesEvent, CertificatesState> {
CertificatesBloc() : super(CertificatesState()) {
on<CertificatesEventFetch>(_onFetch);
on<CertificatesEventSubmit>(_onSubmit);
on<CertificatesEventUpload>(_onUploadPhoto);
on<CertificatesEventDelete>(_onDeleteCertificate);
}
void _onFetch(
CertificatesEventFetch event, Emitter<CertificatesState> emit) async {
emit(state.copyWith(loading: true));
var certificates = await getIt<CertificatesRepository>().getCertificates();
List<StaffCertificate> staffCertificates =
await getIt<CertificatesRepository>().getStaffCertificates();
var items = certificates.map((certificate) {
var staffCertificate = staffCertificates.firstWhereOrNull(
(e) => certificate.id == e.certificate.id,
);
return CertificatesViewModel(
id: staffCertificate?.id,
certificateId: certificate.id,
title: certificate.name,
imageUrl: staffCertificate?.file,
expirationDate: staffCertificate?.expirationDate,
status: staffCertificate?.status,
);
}).toList();
emit(state.copyWith(
loading: false,
certificatesItems: items,
));
}
void _onUploadPhoto(
CertificatesEventUpload event, Emitter<CertificatesState> emit) async {
event.item.uploading = true;
emit(state.copyWith());
try {
var split = event.expirationDate.split('.').reversed;
split = [split.elementAt(0), split.elementAt(2), split.elementAt(1)];
var formattedDate = split.join('-');
var newCertificate = await getIt<CertificatesRepository>()
.putStaffCertificate(
event.item.certificateId, event.path, formattedDate);
event.item.applyNewStaffCertificate(newCertificate);
} finally {
event.item.uploading = false;
emit(state.copyWith());
}
}
void _onSubmit(
CertificatesEventSubmit event, Emitter<CertificatesState> emit) async {
final allCertUploaded = state.certificatesItems.every((element) {
return element.certificateId == '3' ||
element.status == CertificateStatus.pending ||
element.status == CertificateStatus.verified;
});
if (allCertUploaded) {
emit(state.copyWith(success: true));
} else {
emit(state.copyWith(showError: true));
}
}
void _onDeleteCertificate(
CertificatesEventDelete event, Emitter<CertificatesState> emit) async {
emit(state.copyWith(loading: true));
try {
await getIt<CertificatesRepository>()
.deleteStaffCertificate(event.item.id ?? '0');
state.certificatesItems
.firstWhere((element) => element.id == event.item.id)
.clear();
} finally {
emit(state.copyWith(loading: false));
}
}
}

View File

@@ -0,0 +1,22 @@
import 'package:krow/features/profile/certificates/domain/bloc/certificates_state.dart';
sealed class CertificatesEvent {}
class CertificatesEventFetch extends CertificatesEvent {}
class CertificatesEventSubmit extends CertificatesEvent {}
class CertificatesEventUpload extends CertificatesEvent {
final String path;
final String expirationDate;
final CertificatesViewModel item;
CertificatesEventUpload(
{required this.path, required this.expirationDate, required this.item});
}
class CertificatesEventDelete extends CertificatesEvent {
final CertificatesViewModel item;
CertificatesEventDelete(this.item);
}

View File

@@ -0,0 +1,93 @@
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
class CertificatesState {
final bool showError;
final bool loading;
final bool success;
List<CertificatesViewModel> certificatesItems = [];
CertificatesState(
{this.showError = false,
this.certificatesItems = const [],
this.loading = false,
this.success = false});
copyWith(
{bool? showError,
List<CertificatesViewModel>? certificatesItems,
bool? loading,
bool? success}) {
return CertificatesState(
showError: showError ?? this.showError,
certificatesItems: certificatesItems ?? this.certificatesItems,
loading: loading ?? false,
success: success ?? false,
);
}
}
class CertificatesViewModel {
String? id;
String certificateId;
final String title;
bool uploading;
String? imageUrl;
String? expirationDate;
CertificateStatus? status;
CertificatesViewModel({
this.id,
required this.certificateId,
required this.title,
this.expirationDate,
this.imageUrl,
this.uploading = false,
this.status,
});
void clear() {
id = null;
imageUrl = null;
expirationDate = null;
status = null;
}
void applyNewStaffCertificate(StaffCertificate newCertificate) {
id = newCertificate.id;
imageUrl = newCertificate.file;
expirationDate = newCertificate.expirationDate;
status = newCertificate.status;
}
bool get isExpired {
if (expirationDate == null) return false;
DateTime expiration = DateTime.parse(expirationDate!);
DateTime now = DateTime.now();
return now.isAfter(expiration);
}
String getExpirationInfo() {
if (expirationDate == null) return '';
DateTime expiration = DateTime.parse(expirationDate!);
DateTime now = DateTime.now();
Duration difference = expiration.difference(now);
String formatted =
'${expiration.month.toString().padLeft(2, '0')}.${expiration.day.toString().padLeft(2, '0')}.${expiration.year}';
if (difference.inDays <= 0) {
return '$formatted (expired)';
} else if (difference.inDays < 14) {
return '$formatted (expires in ${difference.inDays} days)';
} else {
return formatted;
}
}
bool expireSoon() {
if (expirationDate == null) return false;
DateTime expiration = DateTime.parse(expirationDate!);
DateTime now = DateTime.now();
Duration difference = expiration.difference(now);
return difference.inDays <= 14;
}
}

View File

@@ -0,0 +1,13 @@
import 'package:krow/features/profile/certificates/data/models/certificate_model.dart';
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
abstract class CertificatesRepository {
Future<List<CertificateModel>> getCertificates();
Future<List<StaffCertificate>> getStaffCertificates();
Future<StaffCertificate> putStaffCertificate(
String certificateId, String imagePath, String certificateDate);
Future<void> deleteStaffCertificate(String certificateId);
}

View File

@@ -0,0 +1,219 @@
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:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.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/scroll_layout_helper.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/uploud_image_card.dart';
import 'package:krow/features/profile/certificates/data/models/staff_certificate.dart';
import 'package:krow/features/profile/certificates/domain/bloc/certificates_bloc.dart';
import 'package:krow/features/profile/certificates/domain/bloc/certificates_event.dart';
import 'package:krow/features/profile/certificates/domain/bloc/certificates_state.dart';
import 'package:krow/features/profile/certificates/presentation/screen/certificates_upload_dialog.dart';
import 'package:modal_progress_hud_nsn/modal_progress_hud_nsn.dart';
@RoutePage()
class CertificatesScreen extends StatelessWidget implements AutoRouteWrapper {
const CertificatesScreen({super.key});
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (_) => CertificatesBloc()..add(CertificatesEventFetch()),
child: this,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'certificates'.tr(),
),
body: BlocConsumer<CertificatesBloc, CertificatesState>(
listener: (context, state) {
if (state.success) {
context.router.maybePop();
}
},
builder: (context, state) {
return ModalProgressHUD(
inAsyncCall: state.loading,
child: ScrollLayoutHelper(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'please_indicate_certificates'.tr(),
style: AppTextStyles.bodyTinyMed
.copyWith(color: AppColors.blackGray),
),
if (state.showError) _buildErrorMessage(),
const Gap(24),
_buildUploadProofItems(
context,
state.certificatesItems,
state.showError,
),
],
),
lowerWidget: KwButton.primary(
label: 'confirm'.tr(),
onPressed: () {
BlocProvider.of<CertificatesBloc>(context)
.add(CertificatesEventSubmit());
}),
),
);
},
),
);
}
_buildUploadProofItems(
context,
List<CertificatesViewModel> items,
bool showError,
) {
return Column(
children: items.map(
(item) {
Color? statusColor = _getStatusColor(showError, item);
return UploadImageCard(
title: item.title,
onSelectImage: () {
ImagePicker()
.pickImage(source: ImageSource.gallery)
.then((value) {
if (value != null) {
_showSelectCertificateDialog(context, item, value.path);
}
});
},
onDeleteTap: () {
BlocProvider.of<CertificatesBloc>(context)
.add(CertificatesEventDelete(item));
},
onTap: () {},
imageUrl: item.imageUrl,
inUploading: item.uploading,
statusColor: statusColor,
message: showError && item.imageUrl == null
? 'availability_requires_confirmation'.tr()
: item.status == null
? 'supported_format'.tr()
: (item.status!.name[0].toUpperCase() +
item.status!.name.substring(1).toLowerCase()),
hasError: showError,
padding: const EdgeInsets.only(bottom: 8),
child: _buildExpirationRow(item),
);
},
).toList(),
);
}
Color? _getStatusColor(bool showError, CertificatesViewModel item) {
var statusColor = (showError && item.status == null) ||
item.status == CertificateStatus.declined
? AppColors.statusError
: item.status == CertificateStatus.verified
? AppColors.statusSuccess
: item.status == CertificateStatus.pending
? AppColors.primaryBlue
: null;
return statusColor;
}
Container _buildErrorMessage() {
return Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(8),
decoration: KwBoxDecorations.primaryLight8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 28,
width: 28,
decoration: const BoxDecoration(
shape: BoxShape.circle, color: AppColors.tintRed),
child: Center(
child: Assets.images.icons.alertCircle.svg(),
),
),
const Gap(8),
Expanded(
child: Text(
'listed_certificates_mandatory'.tr(),
style: AppTextStyles.bodyTinyMed
.copyWith(color: AppColors.statusError),
),
),
],
),
);
}
Widget _buildExpirationRow(CertificatesViewModel item) {
var bgColor = item.isExpired
? AppColors.tintRed
: item.expireSoon()
? AppColors.tintYellow
: AppColors.tintGray;
var textColor = item.isExpired
? AppColors.statusError
: item.expireSoon()
? AppColors.statusWarningBody
: null;
return Container(
margin: const EdgeInsets.only(top: 6, right: 4),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(4),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding:
const EdgeInsets.only(left: 6, right: 6, top: 3, bottom: 5),
decoration: BoxDecoration(
color: bgColor, borderRadius: BorderRadius.circular(4)),
child: Text(
'${'expiration_date'.tr()} ',
style: AppTextStyles.bodyTinyReg.copyWith(color: textColor),
)),
const Gap(7),
if (item.expirationDate != null)
Text(
item.getExpirationInfo(),
style: AppTextStyles.bodyTinyMed
.copyWith(color: textColor ?? AppColors.blackGray),
),
],
),
);
}
void _showSelectCertificateDialog(
context, CertificatesViewModel item, imagePath) {
CertificatesUploadDialog.show(context, imagePath).then((expireDate) {
if (expireDate != null) {
BlocProvider.of<CertificatesBloc>(context).add(CertificatesEventUpload(
item: item, path: imagePath, expirationDate: expireDate));
}
});
}
}

View File

@@ -0,0 +1,143 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/common/text_formatters/expiration_date_formatter.dart';
import 'package:krow/core/application/common/validators/certificate_date_validator.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_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_input.dart';
class CertificatesUploadDialog extends StatefulWidget {
final String path;
const CertificatesUploadDialog({
super.key,
required this.path,
});
@override
State<CertificatesUploadDialog> createState() =>
_CertificatesUploadDialogState();
static Future<String?> show(BuildContext context, String path) {
return showDialog<String?>(
context: context,
builder: (BuildContext context) {
return CertificatesUploadDialog(
path: path,
);
},
);
}
}
class _CertificatesUploadDialogState extends State<CertificatesUploadDialog> {
final TextEditingController expiryDateController = TextEditingController();
String? inputError;
@override
void initState() {
super.initState();
expiryDateController.addListener(() {
if(expiryDateController.text.length == 10) {
inputError =
CertificateDateValidator.validate(expiryDateController.text);
}else{
inputError = null;
}
setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Container(
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.9),
decoration: BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.circular(16),
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
..._buildDialogTitle(context),
Material(
type: MaterialType.transparency,
child: KwTextInput(
title: 'expiry_date_1'.tr(),
hintText: 'enter_certificate_expiry_date'.tr(),
controller: expiryDateController,
maxLength: 10,
inputFormatters: [DateTextFormatter()],
keyboardType: TextInputType.datetime,
helperText: inputError,
showError: inputError != null,
),
),
const Gap(12),
_buildImage(context),
const Gap(24),
KwButton.primary(
label: 'save_certificate'.tr(),
disabled: expiryDateController.text.length != 10 || inputError != null,
onPressed: () {
Navigator.of(context).pop(expiryDateController.text);
},
),
],
),
),
),
),
),
);
}
ClipRRect _buildImage(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(widget.path),
width: MediaQuery.of(context).size.width - 80,
fit: BoxFit.contain,
),
);
}
List<Widget> _buildDialogTitle(BuildContext context) {
return [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'add_certificate_expiry_date'.tr(),
style: AppTextStyles.bodyLargeMed,
),
GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
child: Assets.images.icons.x.svg(),
),
],
),
const Gap(8),
Text(
'please_enter_expiry_date'.tr(),
style: AppTextStyles.bodySmallReg.copyWith(color: AppColors.blackGray),
),
const SizedBox(height: 24),
];
}
}

View File

@@ -0,0 +1,80 @@
import 'dart:developer';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/data/static/email_validation_constants.dart';
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
@injectable
class EmailVerificationService {
static const recentLoginRequired = 'requires-recent-login';
static const reLoginRequired = 'requires-re-login';
static const tokenExpired = 'user-token-expired';
final FirebaseAuth _auth = FirebaseAuth.instance;
String? _currentUserPhone;
String get currentUserPhone {
_currentUserPhone ??= _auth.currentUser?.phoneNumber;
return _currentUserPhone ?? '';
}
Future<void> sendVerificationEmail({required String email}) async {
log('Sending email verification $email');
await _auth.currentUser?.verifyBeforeUpdateEmail(
email,
// TODO: Enabling this code will require enabling Firebase Dynamic Links.
// ActionCodeSettings(
// url: 'https://staging.app.krow.develop.express/emailLinkAuth/',
// androidPackageName: dotenv.get('ANDROID_PACKAGE'),
// iOSBundleId: dotenv.get('IOS_BUNDLE_ID'),
// androidInstallApp: true,
// handleCodeInApp: true,
// linkDomain: 'krow-staging.firebaseapp.com',
// ),
);
final sharedPrefs = await SharedPreferences.getInstance();
sharedPrefs.setString(
EmailValidationConstants.storedEmailKey,
email,
);
}
bool checkEmailForVerification({required String email}) {
final user = _auth.currentUser;
if (user == null || user.email == null) return false;
return email == user.email && user.emailVerified;
}
Future<bool> isUserEmailVerified({
required String newEmail,
}) async {
await _auth.currentUser?.reload();
final user = _auth.currentUser;
log(
'Current user email: ${user?.email}, '
'verified: ${user?.emailVerified}, '
'Expected email: $newEmail',
);
if (user == null) {
throw FirebaseAuthException(code: reLoginRequired);
}
return (user.emailVerified) && user.email == newEmail;
}
ReAuthRequirement isReAuthenticationRequired(String errorCode) {
return switch (errorCode) {
recentLoginRequired => ReAuthRequirement.recent,
reLoginRequired || tokenExpired => ReAuthRequirement.immediate,
_ => ReAuthRequirement.none,
};
}
}

View File

@@ -0,0 +1,132 @@
import 'dart:async';
import 'dart:developer';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/core/sevices/auth_state_service/auth_service.dart';
import 'package:krow/features/profile/email_verification/data/email_verification_service.dart';
part 'email_verification_event.dart';
part 'email_verification_state.dart';
/// This BLoC is currently unfinished.
/// We faced an issue with the Firebase email verification process. As a
/// security-sensitive action, it requires recent user authentication (seems
/// like around 5-10 minutes), so we have to prompt the user to re-login. Only
/// after that can the verification email be sent.
/// But upon receiving the email and confirming it, another issue arises: the
/// current auth token becomes invalid, and the user is silently signed out from
/// Firebase. On mobile, it can be solved by integrating a deep-link into the
/// verification email, which will allow us to re-authenticate the user with
/// an email link credential in the background.
/// However, there is a possibility that the user might verify his email on a
/// desktop (or any other device), so the link won't be received. In this case,
/// we need to prompt the user to re-authenticate yet again. This will probably
/// be a bad user experience.
class EmailVerificationBloc
extends Bloc<EmailVerificationEvent, EmailVerificationState> {
EmailVerificationBloc() : super(const EmailVerificationState()) {
on<SetVerifiedData>((event, emit) async {
emit(state.copyWith(
email: event.verifiedEmail,
userPhone: event.userPhone,
));
add(const SendVerificationEmail());
});
on<SendVerificationEmail>((event, emit) async {
try {
await _verificationService.sendVerificationEmail(email: state.email);
emailCheckTimer?.cancel();
emailCheckTimer = Timer.periodic(
const Duration(seconds: 3),
(_) => add(const CheckEmailStatus()),
);
} catch (except) {
log(
'Error in EmailVerificationBloc. On SetVerifiedEmail event',
error: except,
);
if (except is FirebaseAuthException) {
emit(
state.copyWith(
reAuthRequirement:
_verificationService.isReAuthenticationRequired(except.code),
),
);
}
} finally {
emit(state.copyWith(reAuthRequirement: ReAuthRequirement.none));
}
});
on<CheckEmailStatus>((event, emit) async {
bool isEmailVerified = false;
try {
isEmailVerified = await _verificationService.isUserEmailVerified(
newEmail: state.email,
);
} catch (except) {
log(
'Error in EmailVerificationBloc. On CheckEmailStatus event',
error: except,
);
if (except is FirebaseAuthException) {
emit(
state.copyWith(
reAuthRequirement:
_verificationService.isReAuthenticationRequired(except.code),
),
);
}
}
emit(
state.copyWith(
status: isEmailVerified ? StateStatus.success : StateStatus.idle,
),
);
if (state.status == StateStatus.success) emailCheckTimer?.cancel();
});
on<ResendVerificationEmail>((event, emit) {
emit(state.copyWith(reAuthRequirement: ReAuthRequirement.none));
add(const SendVerificationEmail());
});
on<IncomingVerificationLink>((event, emit) async {
try {
await getIt<AuthService>().signInWithEmailLink(
link: event.verificationLink,
);
emit(state.copyWith(status: StateStatus.success));
} catch (except) {
log(
'Error in EmailVerificationBloc. On IncomingVerificationLink event',
error: except,
);
}
if (state.status == StateStatus.success) emailCheckTimer?.cancel();
});
}
final _verificationService = getIt<EmailVerificationService>();
Timer? emailCheckTimer;
@override
Future<void> close() {
emailCheckTimer?.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,31 @@
part of 'email_verification_bloc.dart';
@immutable
sealed class EmailVerificationEvent {
const EmailVerificationEvent();
}
class SetVerifiedData extends EmailVerificationEvent {
const SetVerifiedData({required this.verifiedEmail, required this.userPhone});
final String verifiedEmail;
final String userPhone;
}
class SendVerificationEmail extends EmailVerificationEvent {
const SendVerificationEmail();
}
class CheckEmailStatus extends EmailVerificationEvent {
const CheckEmailStatus();
}
class ResendVerificationEmail extends EmailVerificationEvent {
const ResendVerificationEmail();
}
class IncomingVerificationLink extends EmailVerificationEvent {
const IncomingVerificationLink({required this.verificationLink});
final Uri verificationLink;
}

View File

@@ -0,0 +1,32 @@
part of 'email_verification_bloc.dart';
@immutable
class EmailVerificationState {
const EmailVerificationState({
this.email = '',
this.status = StateStatus.idle,
this.reAuthRequirement = ReAuthRequirement.none,
this.userPhone = '',
});
final String email;
final StateStatus status;
final ReAuthRequirement reAuthRequirement;
final String userPhone;
EmailVerificationState copyWith({
String? email,
StateStatus? status,
ReAuthRequirement? reAuthRequirement,
String? userPhone,
}) {
return EmailVerificationState(
email: email ?? this.email,
status: status ?? this.status,
reAuthRequirement: reAuthRequirement ?? this.reAuthRequirement,
userPhone: userPhone ?? this.userPhone,
);
}
}
enum ReAuthRequirement { none, recent, immediate }

View File

@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
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: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/scroll_layout_helper.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
import 'package:krow/features/profile/email_verification/presentation/widgets/bottom_control_button.dart';
import 'package:krow/features/profile/email_verification/presentation/widgets/verification_actions_widget.dart';
@RoutePage()
class EmailVerificationScreen extends StatefulWidget
implements AutoRouteWrapper {
const EmailVerificationScreen({
super.key,
required this.verifiedEmail,
required this.userPhone,
});
final String verifiedEmail;
final String userPhone;
@override
State<EmailVerificationScreen> createState() =>
_EmailVerificationScreenState();
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider<EmailVerificationBloc>(
create: (context) => EmailVerificationBloc()
..add(SetVerifiedData(
verifiedEmail: verifiedEmail,
userPhone: userPhone,
)),
child: this,
);
}
}
class _EmailVerificationScreenState extends State<EmailVerificationScreen> {
StreamSubscription<Uri>? _appLinkSubscription;
@override
void initState() {
super.initState();
final context = this.context;
_appLinkSubscription = AppLinks().uriLinkStream.listen(
(link) {
if (!context.mounted) return;
context
.read<EmailVerificationBloc>()
.add(IncomingVerificationLink(verificationLink: link));
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'email_verification'.tr(),
),
body: ScrollLayoutHelper(
padding: const EdgeInsets.all(16),
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(4),
Text(
'check_your_email'.tr(),
style: AppTextStyles.headingH1,
textAlign: TextAlign.start,
),
const Gap(8),
Text(
'verification_link_sent'
.tr(args: [widget.verifiedEmail]),
style: AppTextStyles.bodyMediumReg.copyWith(
color: AppColors.blackGray,
),
textAlign: TextAlign.start,
),
const Gap(24),
const VerificationActionsWidget(),
],
),
lowerWidget: const BottomControlButton(),
),
);
}
@override
void dispose() {
_appLinkSubscription?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,68 @@
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/gen/assets.gen.dart';
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.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/email_verification/domain/bloc/email_verification_bloc.dart';
class BottomControlButton extends StatelessWidget {
const BottomControlButton({super.key});
void _listenHandler(BuildContext context, EmailVerificationState state) {
if (state.reAuthRequirement == ReAuthRequirement.none) return;
KwDialog.show(
context: context,
icon: Assets.images.icons.alertTriangle,
state: KwDialogState.negative,
barrierDismissible: false,
title: 'additional_action_needed'.tr(),
message: state.reAuthRequirement == ReAuthRequirement.recent
? 'email_verification_security_sensitive'.tr(args: [state.userPhone])
: 'unable_to_validate_email_status'.tr(),
primaryButtonLabel: 'Continue'.tr(),
onPrimaryButtonPressed: (dialogContext) async {
final isReAuthenticated = await dialogContext.pushRoute<bool>(
PhoneReLoginFlowRoute(userPhone: state.userPhone),
);
if (dialogContext.mounted) Navigator.maybePop(dialogContext);
if (isReAuthenticated == true &&
context.mounted &&
state.reAuthRequirement == ReAuthRequirement.recent) {
context
.read<EmailVerificationBloc>()
.add(const ResendVerificationEmail());
}
},
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<EmailVerificationBloc, EmailVerificationState>(
buildWhen: (previous, current) => previous.status != current.status,
listenWhen: (previous, current) =>
previous.reAuthRequirement != current.reAuthRequirement,
listener: _listenHandler,
builder: (context, state) {
return KwLoadingOverlay(
shouldShowLoading: state.status == StateStatus.loading,
child: KwButton.primary(
label: 'Continue'.tr(),
disabled: state.status != StateStatus.success,
onPressed: () {
context.maybePop(true);
},
),
);
},
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
class VerificationActionsWidget extends StatefulWidget {
const VerificationActionsWidget({super.key});
@override
State<VerificationActionsWidget> createState() =>
_VerificationActionsWidgetState();
}
//TODO: Finish this widget incorporating increasing timer on each code resend.
class _VerificationActionsWidgetState extends State<VerificationActionsWidget> {
double secondsToHold = 1;
bool onHold = false;
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.sizeOf(context).height;
return Center(
child: Padding(
padding: EdgeInsets.only(top: screenHeight / 4.6),
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: 'didnt_receive'.tr(),
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
children: [
TextSpan(
text: 'resend'.tr(),
style: AppTextStyles.bodyMediumSmb,
recognizer: TapGestureRecognizer()
..onTap = () {
context
.read<EmailVerificationBloc>()
.add(const ResendVerificationEmail());
},
),
TextSpan(
text: ' ${'or'.tr()} ',
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
),
TextSpan(
text: 'contact_support_1'.tr(),
style: AppTextStyles.bodyMediumSmb,
recognizer: TapGestureRecognizer()..onTap = () {
//TODO: Add Contact support action.
},
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/features/profile/emergency_contacts/data/gql_schemas.dart';
import 'package:krow/features/profile/emergency_contacts/data/models/emergency_contact_model.dart';
@injectable
class EmergencyContactApiProvider {
final ApiClient _apiClient;
EmergencyContactApiProvider(this._apiClient);
Stream<List<EmergencyContactModel>> getStaffEmergencyContacts() async* {
await for (var response in _apiClient.queryWithCache(
schema: getEmergencyContactsQuerySchema,
)) {
if (response == null || response.data == null) continue;
if (response.data == null || response.data!.isEmpty) {
if (response.source?.name == 'cache') continue;
if (response.hasException) {
throw Exception(response.exception.toString());
}
}
final contactsData =
(response.data?['me'] as Map<String, dynamic>?)?['emergency_contacts']
as List<dynamic>? ??
[];
yield [
for (final contact in contactsData)
EmergencyContactModel.fromJson(
contact as Map<String, dynamic>? ?? {},
),
];
}
}
Future<List<EmergencyContactModel>?> updateEmergencyContacts(
List<EmergencyContactModel> contacts,
) async {
var result = await _apiClient.mutate(
schema: updateEmergencyContactsMutationSchema,
body: {
'input': [
for (final contact in contacts) contact.toJson(),
],
},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!.isEmpty) return null;
//TODO(Heorhii) add contacts return
return null;
}
}

View File

@@ -0,0 +1,9 @@
import 'package:krow/features/profile/emergency_contacts/data/models/emergency_contact_model.dart';
abstract class EmergencyContactRepository {
Stream<List<EmergencyContactModel>> getStaffEmergencyContacts();
Future<List<EmergencyContactModel>?> updateEmergencyContacts(
List<EmergencyContactModel> contacts,
);
}

View File

@@ -0,0 +1,26 @@
const String getEmergencyContactsQuerySchema = r'''
query StaffEmergencyContacts {
me {
id
emergency_contacts {
id
first_name
last_name
phone
}
}
}
''';
const String updateEmergencyContactsMutationSchema = r'''
mutation SaveEmergencyContacts ($input: [CreateStaffEmergencyContactsInput!]) {
attach_staff_emergency_contacts(contacts: $input) {
emergency_contacts {
id
first_name
last_name
phone
}
}
}
''';

View File

@@ -0,0 +1,58 @@
import 'package:flutter/foundation.dart';
@immutable
final class EmergencyContactModel {
const EmergencyContactModel({
this.contactId,
required this.firstName,
required this.lastName,
required this.phoneNumber,
});
const EmergencyContactModel.empty({
this.contactId,
this.firstName = '',
this.lastName = '',
this.phoneNumber = '',
});
factory EmergencyContactModel.fromJson(Map<String, dynamic> json) {
return EmergencyContactModel(
contactId: json['id'] as String?,
firstName: json['first_name'] as String? ?? '',
lastName: json['last_name'] as String? ?? '',
phoneNumber: json['phone'] as String? ?? '',
);
}
final String? contactId;
final String firstName;
final String lastName;
final String phoneNumber;
bool get isFilled =>
firstName.isNotEmpty && lastName.isNotEmpty && phoneNumber.isNotEmpty;
EmergencyContactModel copyWith({
String? contactId,
String? firstName,
String? lastName,
String? phoneNumber,
bool? isSaved,
}) {
return EmergencyContactModel(
contactId: contactId ?? this.contactId,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
phoneNumber: phoneNumber ?? this.phoneNumber,
);
}
Map<String, dynamic> toJson() {
return {
'first_name': firstName,
'last_name': lastName,
'phone': phoneNumber,
};
}
}

View File

@@ -0,0 +1,125 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/emergency_contacts/data/emergency_contact_repository.dart';
import 'package:krow/features/profile/emergency_contacts/data/models/emergency_contact_model.dart';
part 'emergency_contacts_event.dart';
part 'emergency_contacts_state.dart';
class EmergencyContactsBloc
extends Bloc<EmergencyContactsEvent, EmergencyContactsState> {
EmergencyContactsBloc() : super(const EmergencyContactsState()) {
on<InitializeEmergencyContactsEvent>(_handleInitializeEvent);
on<AddNewContactEvent>(_handleAddNewContactEvent);
on<UpdateContactEvent>(_handleUpdateContactEvent);
on<DeleteContactEvent>(_handleDeleteContactEvent);
on<SaveContactsChanges>(_handleSaveContactsChanges);
}
Future<void> _handleInitializeEvent(
InitializeEmergencyContactsEvent event,
Emitter<EmergencyContactsState> emit,
) async {
emit(
state.copyWith(
isInEditMode: event.isInEditMode,
contacts: event.isInEditMode
? state.contacts
: [const EmergencyContactModel.empty()],
status: event.isInEditMode ? StateStatus.loading : StateStatus.idle,
),
);
if (!state.isInEditMode) return;
try {
await for (final emergencyContacts
in getIt<EmergencyContactRepository>().getStaffEmergencyContacts()) {
if (emergencyContacts.isEmpty) continue;
emit(
state.copyWith(
contacts: emergencyContacts,
status: StateStatus.idle,
isListReducible: emergencyContacts.length > 1,
),
);
}
} catch (except) {
log(except.toString());
}
if (state.status == StateStatus.loading) {
emit(state.copyWith(status: StateStatus.idle));
}
}
void _handleAddNewContactEvent(
AddNewContactEvent event,
Emitter<EmergencyContactsState> emit,
) {
emit(
state.copyWith(
contacts: [
...state.contacts,
const EmergencyContactModel.empty(),
],
isListReducible: true,
),
);
}
void _handleUpdateContactEvent(
UpdateContactEvent event,
Emitter<EmergencyContactsState> emit,
) {
final updatedContact = state.contacts[event.index].copyWith(
firstName: event.firstName,
lastName: event.lastName,
phoneNumber: event.phoneNumber,
);
emit(
state.copyWith(
contacts: List.from(state.contacts)..[event.index] = updatedContact,
),
);
}
void _handleDeleteContactEvent(
DeleteContactEvent event,
Emitter<EmergencyContactsState> emit,
) {
emit(
state.copyWith(
contacts: List.from(state.contacts)..removeAt(event.index),
isListReducible: state.contacts.length > 2,
),
);
}
Future<void> _handleSaveContactsChanges(
SaveContactsChanges event,
Emitter<EmergencyContactsState> emit,
) async {
emit(state.copyWith(status: StateStatus.loading));
try {
await getIt<EmergencyContactRepository>().updateEmergencyContacts(
state.contacts,
);
} catch (except) {
log(except.toString());
}
emit(state.copyWith(status: StateStatus.success));
}
}

View File

@@ -0,0 +1,34 @@
part of 'emergency_contacts_bloc.dart';
@immutable
sealed class EmergencyContactsEvent {}
class InitializeEmergencyContactsEvent extends EmergencyContactsEvent {
InitializeEmergencyContactsEvent(this.isInEditMode);
final bool isInEditMode;
}
class AddNewContactEvent extends EmergencyContactsEvent {}
class UpdateContactEvent extends EmergencyContactsEvent {
UpdateContactEvent({
required this.index,
required this.firstName,
required this.lastName,
required this.phoneNumber,
});
final int index;
final String firstName;
final String lastName;
final String phoneNumber;
}
class DeleteContactEvent extends EmergencyContactsEvent {
DeleteContactEvent({required this.index});
final int index;
}
class SaveContactsChanges extends EmergencyContactsEvent {}

View File

@@ -0,0 +1,32 @@
part of 'emergency_contacts_bloc.dart';
@immutable
final class EmergencyContactsState {
const EmergencyContactsState({
this.contacts = const [],
this.status = StateStatus.idle,
this.isInEditMode = true,
this.isListReducible = false,
});
final List<EmergencyContactModel> contacts;
final StateStatus status;
final bool isInEditMode;
final bool isListReducible;
bool get isFilled => contacts.indexWhere((contact) => !contact.isFilled) < 0;
EmergencyContactsState copyWith({
List<EmergencyContactModel>? contacts,
StateStatus? status,
bool? isInEditMode,
bool? isListReducible,
}) {
return EmergencyContactsState(
contacts: contacts ?? this.contacts,
status: status ?? this.status,
isInEditMode: isInEditMode ?? this.isInEditMode,
isListReducible: isListReducible ?? this.isListReducible,
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/emergency_contacts/data/emergency_contact_api_provider.dart';
import 'package:krow/features/profile/emergency_contacts/data/emergency_contact_repository.dart';
import 'package:krow/features/profile/emergency_contacts/data/models/emergency_contact_model.dart';
@LazySingleton(as: EmergencyContactRepository)
class EmergencyContactRepositoryImpl implements EmergencyContactRepository {
EmergencyContactRepositoryImpl({
required EmergencyContactApiProvider apiProvider,
}) : _apiProvider = apiProvider;
final EmergencyContactApiProvider _apiProvider;
@override
Stream<List<EmergencyContactModel>> getStaffEmergencyContacts() {
return _apiProvider.getStaffEmergencyContacts();
}
@override
Future<List<EmergencyContactModel>?> updateEmergencyContacts(
List<EmergencyContactModel> contacts,
) {
return _apiProvider.updateEmergencyContacts(contacts);
}
}

View File

@@ -0,0 +1,115 @@
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:gap/gap.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/emergency_contacts/domain/bloc/emergency_contacts_bloc.dart';
import 'package:krow/features/profile/emergency_contacts/presentation/widgets/bottom_control_button.dart';
import 'package:krow/features/profile/emergency_contacts/presentation/widgets/contacts_list_sliver.dart';
@RoutePage()
class EmergencyContactsScreen extends StatelessWidget
implements AutoRouteWrapper {
const EmergencyContactsScreen({
super.key,
this.isInEditMode = true,
});
final bool isInEditMode;
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (BuildContext context) => EmergencyContactsBloc()
..add(InitializeEmergencyContactsEvent(isInEditMode)),
child: this,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
extendBody: true,
appBar: KwAppBar(
titleText: 'emergency_contact'.tr(),
centerTitle: true,
showNotification: isInEditMode,
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: CustomScrollView(
primary: false,
slivers: [
if (!isInEditMode)
SliverList.list(
children: [
const Gap(16),
Text(
'add_emergency_contacts'.tr(),
style: Theme.of(context).textTheme.titleLarge,
),
const Gap(8),
RichText(
text: TextSpan(
text: 'provide_emergency_contact'.tr(),
style: AppTextStyles.bodyMediumReg
.copyWith(color: AppColors.blackGray),
children: [
TextSpan(
text: ' ${'must_have_one_contact'.tr()}',
style: AppTextStyles.bodyMediumSmb,
),
],
),
),
const Gap(8),
],
),
const SliverToBoxAdapter(
child: Gap(16),
),
const ContactsListSliver(),
SliverList.list(
children: [
KwButton.outlinedPrimary(
fit: KwButtonFit.shrinkWrap,
label: 'add_more'.tr(),
leftIcon: Assets.images.icons.add,
onPressed: () {
context.read<EmergencyContactsBloc>().add(
AddNewContactEvent(),
);
},
),
const Gap(8),
Text(
'add_additional_contact'.tr(),
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(120),
],
),
],
),
),
bottomNavigationBar: SafeArea(
top: false,
bottom: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: BottomControlButton(
isInEditMode: isInEditMode,
footerActionName:
isInEditMode ? 'save_changes'.tr() : 'save_and_continue'.tr(),
),
),
),
);
}
}

View File

@@ -0,0 +1,95 @@
import 'package:auto_route/auto_route.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_button.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_loading_overlay.dart';
import 'package:krow/features/profile/emergency_contacts/domain/bloc/emergency_contacts_bloc.dart';
class BottomControlButton extends StatefulWidget {
const BottomControlButton({
super.key,
required this.isInEditMode,
required this.footerActionName,
});
final bool isInEditMode;
final String footerActionName;
static const _height = 52.0;
@override
State<BottomControlButton> createState() => _BottomControlButtonState();
}
class _BottomControlButtonState extends State<BottomControlButton>
with WidgetsBindingObserver {
bool isVisible = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeMetrics() {
if (View.of(context).viewInsets.bottom > 0 && isVisible) {
setState(() => isVisible = false);
} else if (View.of(context).viewInsets.bottom == 0 && !isVisible) {
setState(() => isVisible = true);
}
}
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
duration: Durations.short2,
opacity: isVisible ? 1 : 0,
child: AnimatedSize(
duration: Durations.short2,
alignment: Alignment.bottomCenter,
child: BlocConsumer<EmergencyContactsBloc, EmergencyContactsState>(
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 (widget.isInEditMode) {
Navigator.pop(context);
} else {
context.router.push(
MobilityRoute(isInEditMode: false),
);
}
}
},
builder: (context, state) {
return KwLoadingOverlay(
shouldShowLoading: state.status == StateStatus.loading,
child: KwButton.primary(
label: widget.footerActionName,
height: BottomControlButton._height,
disabled: !state.isFilled,
onPressed: () {
context.read<EmergencyContactsBloc>().add(
SaveContactsChanges(),
);
},
),
);
},
),
),
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}

View File

@@ -0,0 +1,167 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:krow/core/application/common/int_extensions.dart';
import 'package:krow/core/presentation/gen/assets.gen.dart';
import 'package:krow/core/presentation/styles/kw_box_decorations.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_input.dart';
import 'package:krow/core/presentation/widgets/ui_kit/kw_phone_input.dart';
import 'package:krow/features/profile/emergency_contacts/data/models/emergency_contact_model.dart';
class ContactFormWidget extends StatefulWidget {
const ContactFormWidget({
super.key,
required this.contactModel,
required this.index,
this.onDelete,
this.onContactUpdate,
});
final EmergencyContactModel contactModel;
final int index;
final VoidCallback? onDelete;
final void Function({
required String firstName,
required String lastName,
required String phoneNumber,
})? onContactUpdate;
@override
State<ContactFormWidget> createState() => _ContactFormWidgetState();
}
class _ContactFormWidgetState extends State<ContactFormWidget> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _phoneController = TextEditingController();
Timer? _contactUpdateTimer;
void _scheduleContactUpdate() {
if (widget.onContactUpdate == null) return;
_contactUpdateTimer?.cancel();
_contactUpdateTimer = Timer(
const Duration(microseconds: 500),
() => widget.onContactUpdate!(
firstName: _firstNameController.text,
lastName: _lastNameController.text,
phoneNumber: _phoneController.text,
),
);
}
@override
void initState() {
super.initState();
_firstNameController.text = widget.contactModel.firstName;
_lastNameController.text = widget.contactModel.lastName;
_phoneController.text = widget.contactModel.phoneNumber;
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 24),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 24,
),
decoration: KwBoxDecorations.primaryLight12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 16,
child: Stack(
clipBehavior: Clip.none,
children: [
SizedBox(
width: double.maxFinite,
child: Text(
'contact_details'.tr(
namedArgs: {
'index': (widget.index + 1).toOrdinal(),
},
),
style: AppTextStyles.bodyMediumMed,
overflow: TextOverflow.ellipsis,
),
),
Positioned(
right: -18,
top: -18,
child: _DeleteIconWidget(
onDelete: widget.onDelete,
),
),
],
),
),
const Gap(12),
KwTextInput(
title: 'first_name'.tr(),
hintText: '',
keyboardType: TextInputType.name,
controller: _firstNameController,
onChanged: (_) => _scheduleContactUpdate(),
),
const Gap(8),
KwTextInput(
title: 'last_name'.tr(),
hintText: '',
keyboardType: TextInputType.name,
controller: _lastNameController,
onChanged: (_) => _scheduleContactUpdate(),
),
const Gap(8),
KwPhoneInput(
title: 'phone_number'.tr(),
controller: _phoneController,
onChanged: (_) => _scheduleContactUpdate(),
),
],
),
);
}
@override
void dispose() {
_contactUpdateTimer?.cancel();
_firstNameController.dispose();
_lastNameController.dispose();
_phoneController.dispose();
super.dispose();
}
}
class _DeleteIconWidget extends StatelessWidget {
const _DeleteIconWidget({this.onDelete});
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: Durations.short4,
child: onDelete != null
? IconButton(
onPressed: onDelete,
icon: Assets.images.icons.delete.svg(
height: 16,
width: 16,
colorFilter: const ColorFilter.mode(
AppColors.statusError,
BlendMode.srcIn,
),
),
)
: const SizedBox(),
);
}
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/features/profile/emergency_contacts/data/models/emergency_contact_model.dart';
import 'package:krow/features/profile/emergency_contacts/domain/bloc/emergency_contacts_bloc.dart';
import 'package:krow/features/profile/emergency_contacts/presentation/widgets/contact_form_widget.dart';
class ContactsListSliver extends StatefulWidget {
const ContactsListSliver({
super.key,
});
@override
State<ContactsListSliver> createState() => _ContactsListSliverState();
}
class _ContactsListSliverState extends State<ContactsListSliver> {
final _listKey = GlobalKey<SliverAnimatedListState>();
late final _contactsBloc = context.read<EmergencyContactsBloc>();
int _itemsCount = 0;
EmergencyContactsState get _contactsState => _contactsBloc.state;
bool _listenWhenHandler(
EmergencyContactsState previous,
EmergencyContactsState current,
) =>
previous.contacts != current.contacts;
void _addListItem(int index) {
_listKey.currentState?.insertItem(
index,
duration: Durations.short3,
);
_itemsCount++;
}
void _addSingleListItem({required int index}) => _addListItem(index);
Future<void> _addMultipleListItems({required int addedItemsLength}) async {
_listKey.currentState?.insertAllItems(
_itemsCount,
addedItemsLength,
duration: Durations.short3,
);
_itemsCount += addedItemsLength;
await Future<void>.delayed(Durations.short2);
}
Future<void> _listenHandler(
BuildContext context,
EmergencyContactsState state,
) async {
if (state.contacts.length <= _itemsCount) return;
if (state.contacts.length - _itemsCount == 1) {
_addSingleListItem(index: state.contacts.length - 1);
} else {
await _addMultipleListItems(
addedItemsLength: state.contacts.length - _itemsCount,
);
}
}
void _removeListItem({
required int index,
required EmergencyContactModel contactData,
}) {
_listKey.currentState?.removeItem(
index,
(context, animation) {
return FadeTransition(
opacity: CurvedAnimation(
parent: animation,
curve: const Interval(0, 0.5),
),
child: ScaleTransition(
scale: CurvedAnimation(
parent: animation,
curve: const Interval(0.1, 0.6),
),
child: SizeTransition(
sizeFactor: animation,
child: ContactFormWidget(
contactModel: contactData,
index: index,
),
),
),
);
},
duration: Durations.short4,
);
_itemsCount--;
}
@override
void initState() {
super.initState();
_itemsCount = _contactsState.contacts.length;
}
@override
Widget build(BuildContext context) {
return BlocListener<EmergencyContactsBloc, EmergencyContactsState>(
bloc: _contactsBloc,
listenWhen: _listenWhenHandler,
listener: _listenHandler,
child: SliverAnimatedList(
key: _listKey,
initialItemCount: _contactsState.contacts.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.3, 0),
end: Offset.zero,
).animate(animation),
child: ContactFormWidget(
key: ValueKey(_contactsState.contacts[index]),
contactModel: _contactsState.contacts[index],
index: index,
onContactUpdate: ({
required String firstName,
required String lastName,
required String phoneNumber,
}) {
context.read<EmergencyContactsBloc>().add(
UpdateContactEvent(
index: index,
firstName: firstName,
lastName: lastName,
phoneNumber: phoneNumber,
),
);
},
onDelete: _contactsState.isListReducible
? () {
_removeListItem(
index: index,
contactData: _contactsState.contacts[index],
);
context.read<EmergencyContactsBloc>().add(
DeleteContactEvent(index: index),
);
}
: null,
),
);
},
),
);
}
@override
void dispose() {
super.dispose();
}
}

View File

@@ -0,0 +1,19 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/faq/data/models/faq_entry_model.dart';
@injectable
class FaqApiProvider {
static const List<FaqEntryModel> localFaqData = [
FaqEntryModel(
question: 'How do I create a profile?',
answer: 'To create a profile, download the app, sign up with your email '
'or phone number, and fill in your basic details, including your '
'skills and experience.',
),
];
Future<List<FaqEntryModel>> fetchFaqData() async {
//TODO: Add additional FAQs either received from the backend or as static local data.
return localFaqData;
}
}

View File

@@ -0,0 +1,24 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/faq/data/faq_api_provider.dart';
import 'package:krow/features/profile/faq/data/models/faq_entry_model.dart';
import 'package:krow/features/profile/faq/domain/entities/faq_entry.dart';
import 'package:krow/features/profile/faq/domain/faq_repository.dart';
@Injectable(as: FaqRepository)
class FaqRepositoryImpl implements FaqRepository {
FaqRepositoryImpl({required FaqApiProvider provider}) : _provider = provider;
final FaqApiProvider _provider;
FaqEntry _modelConverter(FaqEntryModel data) {
return FaqEntry(question: data.question, answer: data.answer);
}
@override
Future<List<FaqEntry>> getFaqData() async {
return [
for (final entry in await _provider.fetchFaqData())
_modelConverter(entry),
];
}
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/foundation.dart';
@immutable
class FaqEntryModel {
const FaqEntryModel({required this.question, required this.answer});
factory FaqEntryModel.fromJson(Map<String, dynamic> json) {
//TODO: Add from JSON conversion once the backend is ready.
throw UnimplementedError('Implement from JSON conversion');
}
final String question;
final String answer;
}

View File

@@ -0,0 +1,37 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/faq/domain/entities/faq_entry.dart';
import 'package:krow/features/profile/faq/domain/faq_repository.dart';
part 'faq_event.dart';
part 'faq_state.dart';
class FaqBloc extends Bloc<FaqEvent, FaqState> {
FaqBloc() : super(const FaqState()) {
on<InitializeFAQ>((event, emit) async {
emit(state.copyWith(status: StateStatus.loading));
List<FaqEntry> entries = [];
try {
entries = await getIt<FaqRepository>().getFaqData();
} catch (except) {
log('Error in FaqBloc, on InitializeFAQ', error: except);
emit(state.copyWith(status: StateStatus.error));
}
emit(
state.copyWith(
status: StateStatus.idle,
entries: entries,
),
);
});
}
}

View File

@@ -0,0 +1,10 @@
part of 'faq_bloc.dart';
@immutable
sealed class FaqEvent {
const FaqEvent();
}
class InitializeFAQ extends FaqEvent {
const InitializeFAQ();
}

View File

@@ -0,0 +1,22 @@
part of 'faq_bloc.dart';
@immutable
class FaqState {
const FaqState({
this.status = StateStatus.idle,
this.entries = const [],
});
final StateStatus status;
final List<FaqEntry> entries;
FaqState copyWith({
StateStatus? status,
List<FaqEntry>? entries,
}) {
return FaqState(
status: status ?? this.status,
entries: entries ?? this.entries,
);
}
}

View File

@@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
@immutable
class FaqEntry {
const FaqEntry({required this.question, required this.answer});
final String question;
final String answer;
}

View File

@@ -0,0 +1,5 @@
import 'package:krow/features/profile/faq/domain/entities/faq_entry.dart';
abstract interface class FaqRepository {
Future<List<FaqEntry>> getFaqData();
}

View File

@@ -0,0 +1,97 @@
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: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/ui_kit/kw_app_bar.dart';
import 'package:krow/features/profile/faq/domain/bloc/faq_bloc.dart';
@RoutePage()
class FaqScreen extends StatelessWidget implements AutoRouteWrapper {
const FaqScreen({super.key});
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) => FaqBloc()..add(const InitializeFAQ()),
child: this,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'FAQ',
showNotification: true,
),
body: CustomScrollView(
primary: false,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList.list(
children: [
Text(
'faq_description'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: AppColors.blackGray,
),
textAlign: TextAlign.start,
),
const Gap(8),
],
),
),
BlocBuilder<FaqBloc, FaqState>(
buildWhen: (previous, current) =>
previous.entries != current.entries,
builder: (context, state) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.separated(
itemCount: state.entries.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
return ExpansionTile(
collapsedBackgroundColor: AppColors.graySecondaryFrame,
backgroundColor: AppColors.graySecondaryFrame,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
iconColor: Colors.black,
collapsedIconColor: AppColors.grayStroke,
childrenPadding: const EdgeInsets.only(
left: 12,
right: 12,
bottom: 16,
),
title: Text(
state.entries[index].question,
style: AppTextStyles.bodyLargeMed,
),
children: [
Text(
state.entries[index].answer,
style: AppTextStyles.bodySmallReg.copyWith(
color: AppColors.blackGray,
),
)
],
);
},
),
);
},
)
],
),
);
}
}

View File

@@ -0,0 +1,24 @@
const String getStaffInclusivityInfoSchema = '''
query GetPersonalInfo {
me {
id
accessibility {
has_car
can_relocate
requires_accommodations
accommodation_details
}
}
}
''';
const String updateStaffInclusivityMutationSchema = '''
mutation UpdateStaffAccessibilityInfo(\$input: UpdateStaffAccessibilitiesInput!) {
update_staff_accessibilities(input: \$input) {
accessibility {
requires_accommodations
accommodation_details
}
}
}
''';

View File

@@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
@immutable
class InclusiveInfoModel {
const InclusiveInfoModel({
required this.areAccommodationsRequired,
required this.accommodationsDetails,
});
factory InclusiveInfoModel.fromJson(Map<String, dynamic> json) {
return InclusiveInfoModel(
areAccommodationsRequired:
json['requires_accommodations'] as bool? ?? false,
accommodationsDetails: json['accommodation_details'] as String? ?? '',
);
}
final bool areAccommodationsRequired;
final String accommodationsDetails;
Map<String, dynamic> toJson() {
return {
'requires_accommodations': areAccommodationsRequired,
'accommodation_details': accommodationsDetails,
};
}
}

View File

@@ -0,0 +1,62 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/features/profile/inclusive/data/gql_shemas.dart';
import 'package:krow/features/profile/inclusive/data/models/inclusive_info_model.dart';
@injectable
class StaffInclusivityApiProvider {
StaffInclusivityApiProvider(this._client);
final ApiClient _client;
Stream<InclusiveInfoModel> getStaffInclusivityInfoWithCache() async* {
await for (var response in _client.queryWithCache(
schema: getStaffInclusivityInfoSchema,
)) {
if (response == null || response.data == null) continue;
if (response.hasException) {
throw Exception(response.exception.toString());
}
try {
yield InclusiveInfoModel.fromJson(
(response.data?['me'] as Map<String, dynamic>?)?['accessibility'] ??
{},
);
} catch (except) {
log(
'Exception in StaffInclusivityApiProvider '
'on getStaffInclusivityInfoWithCache()',
error: except,
);
continue;
}
}
}
Future<InclusiveInfoModel?> updateStaffInclusivityInfoInfo(
InclusiveInfoModel data,
) async {
var result = await _client.mutate(
schema: updateStaffInclusivityMutationSchema,
body: {
'input': data.toJson(),
},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!.isEmpty) return null;
return InclusiveInfoModel.fromJson(
(result.data?['update_staff_accessibilities']
as Map<String, dynamic>?)?['accessibility'] ??
{},
);
}
}

View File

@@ -0,0 +1,9 @@
import 'package:krow/features/profile/inclusive/data/models/inclusive_info_model.dart';
abstract class StaffInclusivityRepository {
Stream<InclusiveInfoModel> getStaffInclusivityInfo();
Future<InclusiveInfoModel?> updateStaffMobilityInfo(
InclusiveInfoModel data,
);
}

View File

@@ -0,0 +1,85 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/inclusive/data/models/inclusive_info_model.dart';
import 'package:krow/features/profile/inclusive/data/staff_inclusivity_repository.dart';
part 'inclusive_info_event.dart';
part 'inclusive_info_state.dart';
class InclusiveInfoBloc extends Bloc<InclusiveInfoEvent, InclusiveInfoState> {
InclusiveInfoBloc() : super(const InclusiveInfoState()) {
on<InitializeInclusiveInfoEvent>((event, emit) async {
emit(
state.copyWith(
isInEditMode: event.isInEditMode,
status: event.isInEditMode ? StateStatus.loading : StateStatus.idle,
),
);
if (!state.isInEditMode) {
emit(
state.copyWith(
areAccommodationsRequired: true,
),
);
return;
}
try {
await for (final inclusivityData
in getIt<StaffInclusivityRepository>().getStaffInclusivityInfo()) {
emit(
state.copyWith(
areAccommodationsRequired:
inclusivityData.areAccommodationsRequired,
accommodationsDetails: inclusivityData.accommodationsDetails,
status: StateStatus.idle,
),
);
}
} catch (except) {
log(except.toString());
}
emit(
state.copyWith(
areAccommodationsRequired: state.areAccommodationsRequired ?? true,
status: state.status == StateStatus.loading
? StateStatus.idle
: state.status,
),
);
});
on<ToggleRequiresAccommodations>((event, emit) {
emit(state.copyWith(areAccommodationsRequired: event.index == 0));
});
on<ChangeAccommodationsDetails>((event, emit) {
emit(state.copyWith(accommodationsDetails: event.details));
});
on<SaveInclusiveInfoChanges>((event, emit) async {
emit(state.copyWith(status: StateStatus.loading));
try {
await getIt<StaffInclusivityRepository>().updateStaffMobilityInfo(
InclusiveInfoModel(
areAccommodationsRequired: state.areAccommodationsRequired ?? false,
accommodationsDetails: state.accommodationsDetails,
),
);
} catch (except) {
emit(state.copyWith(status: StateStatus.idle));
log(except.toString());
}
emit(state.copyWith(status: StateStatus.success));
});
}
}

View File

@@ -0,0 +1,24 @@
part of 'inclusive_info_bloc.dart';
@immutable
sealed class InclusiveInfoEvent {}
class InitializeInclusiveInfoEvent extends InclusiveInfoEvent {
InitializeInclusiveInfoEvent({required this.isInEditMode});
final bool isInEditMode;
}
class ToggleRequiresAccommodations extends InclusiveInfoEvent {
ToggleRequiresAccommodations(this.index);
final int index;
}
class ChangeAccommodationsDetails extends InclusiveInfoEvent {
ChangeAccommodationsDetails(this.details);
final String details;
}
class SaveInclusiveInfoChanges extends InclusiveInfoEvent {}

View File

@@ -0,0 +1,37 @@
part of 'inclusive_info_bloc.dart';
@immutable
class InclusiveInfoState {
const InclusiveInfoState({
this.areAccommodationsRequired,
this.accommodationsDetails = '',
this.isInEditMode = true,
this.status = StateStatus.idle,
});
final bool? areAccommodationsRequired;
final String accommodationsDetails;
final bool isInEditMode;
final StateStatus status;
bool get isFilled {
return (areAccommodationsRequired != null && !areAccommodationsRequired!) ||
accommodationsDetails.isNotEmpty;
}
InclusiveInfoState copyWith({
bool? areAccommodationsRequired,
String? accommodationsDetails,
bool? isInEditMode,
StateStatus? status,
}) {
return InclusiveInfoState(
areAccommodationsRequired:
areAccommodationsRequired ?? this.areAccommodationsRequired,
accommodationsDetails:
accommodationsDetails ?? this.accommodationsDetails,
isInEditMode: isInEditMode ?? this.isInEditMode,
status: status ?? this.status,
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/inclusive/data/models/inclusive_info_model.dart';
import 'package:krow/features/profile/inclusive/data/staff_inclusivity_api_provider.dart';
import 'package:krow/features/profile/inclusive/data/staff_inclusivity_repository.dart';
@Injectable(as: StaffInclusivityRepository)
class StaffMobilityRepositoryImpl extends StaffInclusivityRepository {
StaffMobilityRepositoryImpl({
required StaffInclusivityApiProvider apiProvider,
}) : _apiProvider = apiProvider;
final StaffInclusivityApiProvider _apiProvider;
@override
Stream<InclusiveInfoModel> getStaffInclusivityInfo() {
return _apiProvider.getStaffInclusivityInfoWithCache();
}
@override
Future<InclusiveInfoModel?> updateStaffMobilityInfo(
InclusiveInfoModel data,
) {
return _apiProvider.updateStaffInclusivityInfoInfo(data);
}
}

View File

@@ -0,0 +1,128 @@
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:gap/gap.dart';
import 'package:krow/core/application/common/bool_extension.dart';
import 'package:krow/core/application/routing/routes.gr.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/styles/theme.dart';
import 'package:krow/core/presentation/widgets/scroll_layout_helper.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/core/presentation/widgets/ui_kit/kw_option_selector.dart';
import 'package:krow/features/profile/inclusive/domain/bloc/inclusive_info_bloc.dart';
import 'package:krow/features/profile/inclusive/presentation/widgets/accessibility_details_widget.dart';
@RoutePage()
class InclusiveScreen extends StatelessWidget implements AutoRouteWrapper {
const InclusiveScreen({
super.key,
this.isInEditMode = true,
});
final bool isInEditMode;
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) => InclusiveInfoBloc()
..add(InitializeInclusiveInfoEvent(isInEditMode: isInEditMode)),
child: this,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'inclusive'.tr(),
showNotification: false,
),
body: ScrollLayoutHelper(
padding: const EdgeInsets.all(16),
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(4),
Text(
'inclusive_information'.tr(),
style: AppTextStyles.headingH1,
textAlign: TextAlign.start,
),
const Gap(8),
Text(
'providing_optional_information'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: AppColors.blackGray,
),
textAlign: TextAlign.start,
),
Padding(
padding: const EdgeInsets.only(bottom: 16, top: 24),
child: Text(
'specific_accommodations_question'.tr(),
style: AppTextStyles.headingH3,
),
),
BlocSelector<InclusiveInfoBloc, InclusiveInfoState, bool?>(
selector: (state) => state.areAccommodationsRequired,
builder: (context, areAccommodationsRequired) {
return KwOptionSelector(
selectedIndex: areAccommodationsRequired?.toInt(),
selectedColor: AppColors.blackDarkBgBody,
itemBorder: const Border.fromBorderSide(
BorderSide(color: AppColors.grayStroke),
),
items: ['yes'.tr(), 'no'.tr()],
onChanged: (index) {
context
.read<InclusiveInfoBloc>()
.add(ToggleRequiresAccommodations(index));
},
);
},
),
const Gap(16),
const AccessibilityDetailsWidget(),
const Gap(90),
],
),
lowerWidget: BlocConsumer<InclusiveInfoBloc, InclusiveInfoState>(
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 (isInEditMode) {
Navigator.pop(context);
} else {
context.router.push(
AddressRoute(isInEditMode: false),
);
}
}
},
builder: (context, state) {
return KwLoadingOverlay(
shouldShowLoading: state.status == StateStatus.loading,
child: KwButton.primary(
label: isInEditMode ? 'save_changes'.tr() : 'save_and_continue'.tr(),
height: 52,
disabled: !state.isFilled,
onPressed: () {
context
.read<InclusiveInfoBloc>()
.add(SaveInclusiveInfoChanges());
},
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,73 @@
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/data/enums/state_status.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_input.dart';
import 'package:krow/features/profile/inclusive/domain/bloc/inclusive_info_bloc.dart';
class AccessibilityDetailsWidget extends StatefulWidget {
const AccessibilityDetailsWidget({super.key});
@override
State<AccessibilityDetailsWidget> createState() =>
_AccessibilityDetailsWidgetState();
}
class _AccessibilityDetailsWidgetState
extends State<AccessibilityDetailsWidget> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return BlocConsumer<InclusiveInfoBloc, InclusiveInfoState>(
buildWhen: (previous, current) =>
previous.areAccommodationsRequired !=
current.areAccommodationsRequired ||
previous.status != current.status,
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
_controller.text = state.accommodationsDetails;
},
builder: (context, state) {
return AnimatedOpacity(
opacity: state.areAccommodationsRequired ?? false ? 1 : 0,
duration: Durations.medium2,
child: Column(
children: [
Text(
'describe_accommodations'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: AppColors.blackGray,
),
textAlign: TextAlign.start,
),
const Gap(16),
KwTextInput(
enabled: state.areAccommodationsRequired ?? false,
controller: _controller,
minHeight: 160,
maxLength: 300,
showCounter: true,
radius: 12,
title: 'additional_details'.tr(),
hintText: 'enter_main_text'.tr(),
showError: state.status == StateStatus.error,
helperText: state.status == StateStatus.error
? 'required_to_fill'.tr()
: null,
onChanged: (details) {
context
.read<InclusiveInfoBloc>()
.add(ChangeAccommodationsDetails(details));
},
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,27 @@
const String getStaffLivePhotoSchema = '''
query GetLivePhoto {
me {
id
live_photo
live_photo_obj {
status
url
uploaded_at
}
}
}
''';
const String uploadStaffLivePhotoSchema = '''
mutation UploadStaffLivePhoto(\$file: Upload!) {
upload_staff_live_photo(file: \$file) {
id
live_photo
live_photo_obj {
status
url
uploaded_at
}
}
}
''';

View File

@@ -0,0 +1,66 @@
import 'package:flutter/foundation.dart';
@immutable
class LivePhotoData {
const LivePhotoData({
required this.id,
required this.imageUrl,
required this.imagePath,
required this.status,
required this.timestamp,
});
factory LivePhotoData.fromJson(Map<String, dynamic> json) {
// TODO: For now live_photo_obj returns a placeholder from the backend.
final livePhotoObj = json['live_photo_obj'] as Map<String, dynamic>;
return LivePhotoData(
id: json['id'] as String? ?? '',
imageUrl: json['live_photo'] as String? ?? '',
imagePath: null,
status: LivePhotoStatus.fromString(
livePhotoObj['status'] as String? ?? '',
),
timestamp: DateTime.tryParse(
livePhotoObj['uploaded_at'] as String? ?? '',
) ??
DateTime.now(),
);
}
final String? id;
final String? imageUrl;
final String? imagePath;
final LivePhotoStatus status;
final DateTime timestamp;
LivePhotoData copyWith({
String? id,
String? imageUrl,
String? imagePath,
LivePhotoStatus? status,
DateTime? timestamp,
}) {
return LivePhotoData(
id: id ?? this.id,
imageUrl: imageUrl ?? this.imageUrl,
imagePath: imagePath ?? this.imagePath,
status: status ?? this.status,
timestamp: timestamp ?? this.timestamp,
);
}
}
enum LivePhotoStatus {
pending,
verified,
declined;
static fromString(String value) {
return switch (value) {
'pending' => pending,
'verified' => verified,
_ => declined,
};
}
}

View File

@@ -0,0 +1,63 @@
import 'dart:developer';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/features/profile/live_photo/data/gql_schemas.dart';
import 'package:krow/features/profile/live_photo/data/models/live_photo_data.dart';
@injectable
class StaffLivePhotoApiProvider {
StaffLivePhotoApiProvider(this._client);
final ApiClient _client;
Stream<LivePhotoData> getStaffLivePhotoWithCache() async* {
await for (var response in _client.queryWithCache(
schema: getStaffLivePhotoSchema,
)) {
if (response == null || response.data == null) continue;
if (response.hasException) {
throw Exception(response.exception.toString());
}
try {
yield LivePhotoData.fromJson(
response.data?['me'] as Map<String, dynamic>,
);
} catch (except) {
log(
'Exception in StaffInclusivityApiProvider '
'on getStaffLivePhotoWithCache()',
error: except,
);
continue;
}
}
}
Future<LivePhotoData> uploadStaffLivePhoto(LivePhotoData data) async {
var result = await _client.mutate(
schema: uploadStaffLivePhotoSchema,
body: {
'file': await MultipartFile.fromPath(
'file',
data.imagePath ?? '',
),
},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!.isEmpty) {
throw Exception('Missing Live photo response from server');
}
return LivePhotoData.fromJson(
result.data?['upload_staff_live_photo'] as Map<String, dynamic>,
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/live_photo/data/models/live_photo_data.dart';
import 'package:krow/features/profile/live_photo/data/staff_live_photo_api_provider.dart';
import 'package:krow/features/profile/live_photo/domain/staff_live_photo_repository.dart';
@Injectable(as: StaffLivePhotoRepository)
class StaffLivePhotoRepositoryImpl implements StaffLivePhotoRepository {
StaffLivePhotoRepositoryImpl({
required StaffLivePhotoApiProvider apiProvider,
}) : _apiProvider = apiProvider;
final StaffLivePhotoApiProvider _apiProvider;
@override
Stream<LivePhotoData> getStaffLivePhotoWithCache() {
return _apiProvider.getStaffLivePhotoWithCache();
}
@override
Future<LivePhotoData> uploadStaffLivePhoto(LivePhotoData data) {
return _apiProvider.uploadStaffLivePhoto(data);
}
}

View File

@@ -0,0 +1,80 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/live_photo/data/models/live_photo_data.dart';
import 'package:krow/features/profile/live_photo/domain/staff_live_photo_repository.dart';
part 'live_photo_event.dart';
part 'live_photo_state.dart';
class LivePhotoBloc extends Bloc<LivePhotoEvent, LivePhotoState> {
LivePhotoBloc() : super(const LivePhotoState()) {
on<InitLivePhotoBloc>((event, emit) async {
emit(state.copyWith(status: StateStatus.loading));
try {
await for (final photoData
in _repository.getStaffLivePhotoWithCache()) {
emit(
state.copyWith(
status: StateStatus.idle,
photoData: photoData,
),
);
}
} catch (except) {
log(
except.toString(),
error: except,
);
}
emit(state.copyWith(status: StateStatus.idle));
});
on<AddNewPhotoEvent>((event, emit) async {
final photoData = LivePhotoData(
id: null,
imageUrl: null,
imagePath: event.newImagePath,
status: LivePhotoStatus.pending,
timestamp: DateTime.now(),
);
emit(state.copyWith(photoData: photoData));
try {
await _repository.uploadStaffLivePhoto(photoData);
} catch (except) {
log(
except.toString(),
error: except,
);
emit(
state.copyWith(
photoData: state.photoData?.copyWith(
status: LivePhotoStatus.declined,
),
),
);
}
});
on<DeleteCurrentPhotoEvent>((event, emit) async {
emit(state.removeCurrentPhoto(status: StateStatus.loading));
//TODO: Add photo deletion once backend implements the mutation
await Future<void>.delayed(const Duration(seconds: 1));
emit(state.copyWith(status: StateStatus.idle));
});
}
final StaffLivePhotoRepository _repository =
getIt<StaffLivePhotoRepository>();
}

View File

@@ -0,0 +1,20 @@
part of 'live_photo_bloc.dart';
@immutable
sealed class LivePhotoEvent {
const LivePhotoEvent();
}
class InitLivePhotoBloc extends LivePhotoEvent {
const InitLivePhotoBloc();
}
class AddNewPhotoEvent extends LivePhotoEvent {
const AddNewPhotoEvent({required this.newImagePath});
final String newImagePath;
}
class DeleteCurrentPhotoEvent extends LivePhotoEvent {
const DeleteCurrentPhotoEvent();
}

View File

@@ -0,0 +1,31 @@
part of 'live_photo_bloc.dart';
@immutable
class LivePhotoState {
const LivePhotoState({
this.status = StateStatus.idle,
this.photoData,
});
final StateStatus status;
final LivePhotoData? photoData;
LivePhotoState copyWith({
StateStatus? status,
LivePhotoData? photoData,
}) {
return LivePhotoState(
status: status ?? this.status,
photoData: photoData ?? this.photoData,
);
}
LivePhotoState removeCurrentPhoto({
StateStatus? status,
}) {
return LivePhotoState(
status: status ?? this.status,
photoData: null,
);
}
}

View File

@@ -0,0 +1,7 @@
import 'package:krow/features/profile/live_photo/data/models/live_photo_data.dart';
abstract class StaffLivePhotoRepository {
Stream<LivePhotoData> getStaffLivePhotoWithCache();
Future<LivePhotoData> uploadStaffLivePhoto(LivePhotoData data);
}

View File

@@ -0,0 +1,64 @@
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:gap/gap.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_loading_overlay.dart';
import 'package:krow/features/profile/live_photo/domain/bloc/live_photo_bloc.dart';
import 'package:krow/features/profile/live_photo/presentation/widgets/live_photo_display_card.dart';
import 'package:krow/features/profile/live_photo/presentation/widgets/photo_requirements_card.dart';
@RoutePage()
class LivePhotoScreen extends StatelessWidget implements AutoRouteWrapper {
const LivePhotoScreen({super.key});
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider<LivePhotoBloc>(
create: (context) => LivePhotoBloc()..add(const InitLivePhotoBloc()),
child: this,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'live_photo'.tr(),
showNotification: true,
),
body: ListView(
primary: false,
padding: const EdgeInsets.all(16),
children: [
BlocBuilder<LivePhotoBloc, LivePhotoState>(
buildWhen: (previous, current) => previous.status != current.status,
builder: (context, state) {
return KwLoadingOverlay(
shouldShowLoading: state.status == StateStatus.loading,
child: const PhotoRequirementsCard(),
);
},
),
const Gap(24),
BlocBuilder<LivePhotoBloc, LivePhotoState>(
buildWhen: (previous, current) =>
previous.photoData != current.photoData,
builder: (context, state) {
final photoData = state.photoData;
return AnimatedSwitcher(
duration: Durations.short4,
child: photoData == null
? const SizedBox(height: 56)
: LivePhotoDisplayCard(photoData: photoData),
);
},
),
const Gap(90),
],
),
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:krow/core/application/common/date_time_extension.dart';
import 'package:krow/core/presentation/styles/theme.dart';
import 'package:krow/core/presentation/widgets/uploud_image_card.dart';
import 'package:krow/features/profile/live_photo/domain/bloc/live_photo_bloc.dart';
import 'package:krow/features/profile/live_photo/data/models/live_photo_data.dart';
class LivePhotoDisplayCard extends StatelessWidget {
const LivePhotoDisplayCard({
super.key,
required this.photoData,
});
final LivePhotoData photoData;
String _getPhotoStatus(LivePhotoStatus status) {
return switch (status) {
LivePhotoStatus.pending => 'Pending'.tr(),
LivePhotoStatus.verified => 'Verified'.tr(),
LivePhotoStatus.declined => 'Declined'.tr(),
};
}
Color _getPhotoStatusColor(LivePhotoStatus status) {
return switch (status) {
LivePhotoStatus.pending => AppColors.primaryBlue,
LivePhotoStatus.verified => AppColors.statusSuccess,
LivePhotoStatus.declined => AppColors.statusError,
};
}
@override
Widget build(BuildContext context) {
return UploadImageCard(
title: '${'Photo'.tr()} ${photoData.timestamp.toDayMonthYearString()}',
imageUrl: photoData.imageUrl,
localImagePath: photoData.imagePath,
message: _getPhotoStatus(photoData.status),
statusColor: _getPhotoStatusColor(photoData.status),
onDeleteTap: () {
context.read<LivePhotoBloc>().add(const DeleteCurrentPhotoEvent());
},
onSelectImage: () {
ImagePicker()
.pickImage(
source: ImageSource.camera,
preferredCameraDevice: CameraDevice.front,
)
.then(
(photo) {
if (photo == null || !context.mounted) return;
context.read<LivePhotoBloc>().add(
AddNewPhotoEvent(newImagePath: photo.path),
);
},
);
},
onTap: () {},
);
}
}

View File

@@ -0,0 +1,103 @@
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:image_picker/image_picker.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_button.dart';
import 'package:krow/features/profile/live_photo/domain/bloc/live_photo_bloc.dart';
class PhotoRequirementsCard extends StatelessWidget {
const PhotoRequirementsCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
color: AppColors.grayWhite,
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ensure_meet_requirements'.tr(),
style: AppTextStyles.bodyMediumMed,
),
const Gap(12),
Column(
spacing: 8,
children: [
for (int i = 0; i < requirementsData.length; i++)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 20,
width: 20,
alignment: Alignment.center,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.bgColorDark,
),
child: Text(
'${i + 1}',
style: const TextStyle(
fontFamily: 'Poppins',
fontWeight: FontWeight.w500,
fontSize: 8,
color: AppColors.grayWhite,
),
),
),
const Gap(8),
Expanded(
child: Text(
requirementsData[i].tr(),
style: AppTextStyles.bodySmallReg,
),
),
],
),
],
),
const Gap(16),
BlocSelector<LivePhotoBloc, LivePhotoState, bool>(
selector: (state) => state.photoData != null,
builder: (context, isPhotoTaken) {
return KwButton.primary(
label: isPhotoTaken?'take_new_photo'.tr():'take_photo'.tr(),
onPressed: () {
ImagePicker()
.pickImage(
source: ImageSource.camera,
preferredCameraDevice: CameraDevice.front,
)
.then(
(photo) {
if (photo == null || !context.mounted) return;
context.read<LivePhotoBloc>().add(
AddNewPhotoEvent(newImagePath: photo.path),
);
},
);
},
);
},
),
],
),
);
}
}
const requirementsData = [
'stand_in_well_lit_area',
'ensure_face_visible',
'avoid_filters_obstructions',
];

View File

@@ -0,0 +1,24 @@
const String getStaffMobilityInfoSchema = '''
query GetPersonalInfo {
me {
id
accessibility {
has_car
can_relocate
requires_accommodations
accommodation_details
}
}
}
''';
const String updateStaffMobilityMutationSchema = '''
mutation UpdateStaffAccessibilityInfo(\$input: UpdateStaffAccessibilitiesInput!) {
update_staff_accessibilities(input: \$input) {
accessibility {
has_car
can_relocate
}
}
}
''';

View File

@@ -0,0 +1,26 @@
import 'package:flutter/foundation.dart';
@immutable
class MobilityModel {
const MobilityModel({
required this.hasACar,
required this.canRelocate,
});
factory MobilityModel.fromJson(Map<String, dynamic> json) {
return MobilityModel(
hasACar: json['has_car'] as bool? ?? false,
canRelocate: json['can_relocate'] as bool? ?? false,
);
}
final bool hasACar;
final bool canRelocate;
Map<String, dynamic> toJson() {
return {
'has_car': hasACar,
'can_relocate': canRelocate,
};
}
}

View File

@@ -0,0 +1,60 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:krow/core/application/clients/api/api_client.dart';
import 'package:krow/features/profile/mobility/data/gql_shemas.dart';
import 'package:krow/features/profile/mobility/data/models/mobility_model.dart';
@injectable
class StaffMobilityApiProvider {
StaffMobilityApiProvider(this._client);
final ApiClient _client;
Stream<MobilityModel> getStaffMobilityWithCache() async* {
await for (var response in _client.queryWithCache(
schema: getStaffMobilityInfoSchema,
)) {
if (response == null || response.data == null) continue;
if (response.hasException) {
throw Exception(response.exception.toString());
}
try {
yield MobilityModel.fromJson(
(response.data?['me'] as Map<String, dynamic>?)?['accessibility'] ??
{},
);
} catch (except) {
log(
'Exception in StaffMobilityApiProvider '
'on getStaffMobilityWithCache()',
error: except,
);
continue;
}
}
}
Future<MobilityModel?> updateStaffMobilityInfo(MobilityModel data) async {
var result = await _client.mutate(
schema: updateStaffMobilityMutationSchema,
body: {
'input': data.toJson(),
},
);
if (result.hasException) {
throw Exception(result.exception.toString());
}
if (result.data == null || result.data!.isEmpty) return null;
return MobilityModel.fromJson(
(result.data?['update_staff_accessibilities']
as Map<String, dynamic>?)?['accessibility'] ??
{},
);
}
}

View File

@@ -0,0 +1,9 @@
import 'package:krow/features/profile/mobility/data/models/mobility_model.dart';
abstract class StaffMobilityRepository {
Stream<MobilityModel> getStaffMobility();
Future<MobilityModel?> updateStaffMobilityInfo(
MobilityModel data,
);
}

View File

@@ -0,0 +1,86 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow/core/application/di/injectable.dart';
import 'package:krow/core/data/enums/state_status.dart';
import 'package:krow/features/profile/mobility/data/models/mobility_model.dart';
import 'package:krow/features/profile/mobility/data/staff_mobility_repository.dart';
part 'mobility_event.dart';
part 'mobility_state.dart';
class MobilityBloc extends Bloc<MobilityEvent, MobilityState> {
MobilityBloc() : super(const MobilityState()) {
on<InitializeMobilityEvent>((event, emit) async {
emit(
state.copyWith(
isInEditMode: event.isInEditMode,
status: event.isInEditMode ? StateStatus.loading : StateStatus.idle,
),
);
if (!state.isInEditMode) {
emit(
state.copyWith(
hasACar: true,
canRelocate: true,
),
);
return;
}
try {
await for (final mobilityData
in getIt<StaffMobilityRepository>().getStaffMobility()) {
emit(
state.copyWith(
hasACar: mobilityData.hasACar,
canRelocate: mobilityData.canRelocate,
status: StateStatus.idle,
),
);
}
} catch (except) {
log(except.toString());
}
emit(
state.copyWith(
hasACar: state.hasACar ?? true,
canRelocate: state.canRelocate ?? true,
status: state.status == StateStatus.loading
? StateStatus.idle
: state.status,
),
);
});
on<ToggleCarAvailability>((event, emit) {
emit(state.copyWith(hasACar: event.index == 0));
});
on<ToggleRelocationPossibility>((event, emit) {
emit(state.copyWith(canRelocate: event.index == 0));
});
on<SaveMobilityChanges>((event, emit) async {
emit(state.copyWith(status: StateStatus.loading));
try {
await getIt<StaffMobilityRepository>().updateStaffMobilityInfo(
MobilityModel(
hasACar: state.hasACar ?? false,
canRelocate: state.canRelocate ?? false,
),
);
//resave cached data
getIt<StaffMobilityRepository>().getStaffMobility();
} catch (except) {
emit(state.copyWith(status: StateStatus.idle));
log(except.toString());
}
emit(state.copyWith(status: StateStatus.success));
});
}
}

View File

@@ -0,0 +1,24 @@
part of 'mobility_bloc.dart';
@immutable
sealed class MobilityEvent {}
class InitializeMobilityEvent extends MobilityEvent {
InitializeMobilityEvent({required this.isInEditMode});
final bool isInEditMode;
}
class ToggleCarAvailability extends MobilityEvent {
ToggleCarAvailability(this.index);
final int index;
}
class ToggleRelocationPossibility extends MobilityEvent {
ToggleRelocationPossibility(this.index);
final int index;
}
class SaveMobilityChanges extends MobilityEvent {}

View File

@@ -0,0 +1,30 @@
part of 'mobility_bloc.dart';
@immutable
class MobilityState {
const MobilityState({
this.hasACar,
this.canRelocate,
this.isInEditMode = true,
this.status = StateStatus.idle,
});
final bool? hasACar;
final bool? canRelocate;
final bool isInEditMode;
final StateStatus status;
MobilityState copyWith({
bool? hasACar,
bool? canRelocate,
bool? isInEditMode,
StateStatus? status,
}) {
return MobilityState(
hasACar: hasACar ?? this.hasACar,
canRelocate: canRelocate ?? this.canRelocate,
isInEditMode: isInEditMode ?? this.isInEditMode,
status: status ?? this.status,
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:injectable/injectable.dart';
import 'package:krow/features/profile/mobility/data/models/mobility_model.dart';
import 'package:krow/features/profile/mobility/data/staff_mobility_api_provider.dart';
import 'package:krow/features/profile/mobility/data/staff_mobility_repository.dart';
@LazySingleton(as: StaffMobilityRepository)
class StaffMobilityRepositoryImpl extends StaffMobilityRepository {
StaffMobilityRepositoryImpl({
required StaffMobilityApiProvider apiProvider,
}) : _apiProvider = apiProvider;
final StaffMobilityApiProvider _apiProvider;
@override
Stream<MobilityModel> getStaffMobility() {
return _apiProvider.getStaffMobilityWithCache();
}
@override
Future<MobilityModel?> updateStaffMobilityInfo(MobilityModel data) {
return _apiProvider.updateStaffMobilityInfo(data);
}
}

View File

@@ -0,0 +1,166 @@
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:gap/gap.dart';
import 'package:krow/core/application/common/bool_extension.dart';
import 'package:krow/core/application/routing/routes.gr.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/styles/theme.dart';
import 'package:krow/core/presentation/widgets/scroll_layout_helper.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/core/presentation/widgets/ui_kit/kw_option_selector.dart';
import 'package:krow/features/profile/mobility/domain/bloc/mobility_bloc.dart';
@RoutePage()
class MobilityScreen extends StatelessWidget implements AutoRouteWrapper {
const MobilityScreen({
super.key,
this.isInEditMode = true,
});
final bool isInEditMode;
@override
Widget wrappedRoute(BuildContext context) {
return BlocProvider(
create: (context) => MobilityBloc()
..add(InitializeMobilityEvent(isInEditMode: isInEditMode)),
child: this,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: KwAppBar(
titleText: 'mobility'.tr(),
showNotification: false,
),
body: ScrollLayoutHelper(
padding: const EdgeInsets.all(16),
upperWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(4),
Text(
'mobility_information'.tr(),
style: AppTextStyles.headingH1,
textAlign: TextAlign.start,
),
const Gap(8),
Text(
'help_us_understand_mobility'.tr(),
style: AppTextStyles.bodyMediumReg.copyWith(
color: AppColors.blackGray,
),
textAlign: TextAlign.start,
),
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 16, top: 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'1. ',
style: AppTextStyles.headingH3,
),
Expanded(
child: Text(
'do_you_have_a_car'.tr(),
style: AppTextStyles.headingH3,
),
)
],
),
),
BlocSelector<MobilityBloc, MobilityState, bool?>(
selector: (state) => state.hasACar,
builder: (context, hasACar) {
return KwOptionSelector(
selectedIndex: hasACar?.toInt(),
selectedColor: AppColors.blackDarkBgBody,
itemBorder: const Border.fromBorderSide(
BorderSide(color: AppColors.grayStroke),
),
items: ['yes'.tr(), 'no'.tr()],
onChanged: (index) {
context
.read<MobilityBloc>()
.add(ToggleCarAvailability(index));
},
);
},
),
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 16, top: 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'2. ',
style: AppTextStyles.headingH3,
),
Expanded(
child: Text(
'can_you_relocate'.tr(),
style: AppTextStyles.headingH3,
),
),
],
),
),
BlocSelector<MobilityBloc, MobilityState, bool?>(
selector: (state) => state.canRelocate,
builder: (context, canRelocate) {
return KwOptionSelector(
selectedColor: AppColors.blackDarkBgBody,
itemBorder: const Border.fromBorderSide(
BorderSide(color: AppColors.grayStroke),
),
selectedIndex: canRelocate?.toInt(),
items: ['yes'.tr(), 'no'.tr()],
onChanged: (index) {
context
.read<MobilityBloc>()
.add(ToggleRelocationPossibility(index));
},
);
},
),
const Gap(90),
],
),
lowerWidget: BlocConsumer<MobilityBloc, MobilityState>(
buildWhen: (previous, current) => previous.status != current.status,
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == StateStatus.success) {
if (isInEditMode) {
Navigator.pop(context);
} else {
context.router.push(
InclusiveRoute(isInEditMode: false),
);
}
}
},
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<MobilityBloc>().add(SaveMobilityChanges());
},
),
);
},
),
),
);
}
}

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'],
);
}
}

Some files were not shown because too many files have changed in this diff Show More