feat: legacy mobile apps created
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 can’t 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 can’t 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 can’t 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);
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
part of 'faq_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class FaqEvent {
|
||||
const FaqEvent();
|
||||
}
|
||||
|
||||
class InitializeFAQ extends FaqEvent {
|
||||
const InitializeFAQ();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'package:krow/features/profile/faq/domain/entities/faq_entry.dart';
|
||||
|
||||
abstract interface class FaqRepository {
|
||||
Future<List<FaqEntry>> getFaqData();
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'] ??
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: () {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'] ??
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
''';
|
||||
@@ -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
Reference in New Issue
Block a user