| @@ -370,7 +370,7 @@ | |||
| CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; | |||
| CODE_SIGN_IDENTITY = "Apple Development"; | |||
| CODE_SIGN_STYLE = Automatic; | |||
| CURRENT_PROJECT_VERSION = 29; | |||
| CURRENT_PROJECT_VERSION = 30; | |||
| DEVELOPMENT_TEAM = C3DTD2JH94; | |||
| ENABLE_BITCODE = NO; | |||
| FRAMEWORK_SEARCH_PATHS = ( | |||
| @@ -386,7 +386,7 @@ | |||
| "$(inherited)", | |||
| "$(PROJECT_DIR)/Flutter", | |||
| ); | |||
| MARKETING_VERSION = 1.1.10; | |||
| MARKETING_VERSION = 1.1.11; | |||
| PRODUCT_BUNDLE_IDENTIFIER = vn.azteam.farmdemo; | |||
| PRODUCT_NAME = "$(TARGET_NAME)"; | |||
| PROVISIONING_PROFILE_SPECIFIER = ""; | |||
| @@ -512,7 +512,7 @@ | |||
| CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; | |||
| CODE_SIGN_IDENTITY = "Apple Development"; | |||
| CODE_SIGN_STYLE = Automatic; | |||
| CURRENT_PROJECT_VERSION = 29; | |||
| CURRENT_PROJECT_VERSION = 30; | |||
| DEVELOPMENT_TEAM = C3DTD2JH94; | |||
| ENABLE_BITCODE = NO; | |||
| FRAMEWORK_SEARCH_PATHS = ( | |||
| @@ -528,7 +528,7 @@ | |||
| "$(inherited)", | |||
| "$(PROJECT_DIR)/Flutter", | |||
| ); | |||
| MARKETING_VERSION = 1.1.10; | |||
| MARKETING_VERSION = 1.1.11; | |||
| PRODUCT_BUNDLE_IDENTIFIER = vn.azteam.farmdemo; | |||
| PRODUCT_NAME = "$(TARGET_NAME)"; | |||
| PROVISIONING_PROFILE_SPECIFIER = ""; | |||
| @@ -548,7 +548,7 @@ | |||
| CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; | |||
| CODE_SIGN_IDENTITY = "Apple Development"; | |||
| CODE_SIGN_STYLE = Automatic; | |||
| CURRENT_PROJECT_VERSION = 29; | |||
| CURRENT_PROJECT_VERSION = 30; | |||
| DEVELOPMENT_TEAM = C3DTD2JH94; | |||
| ENABLE_BITCODE = NO; | |||
| FRAMEWORK_SEARCH_PATHS = ( | |||
| @@ -564,7 +564,7 @@ | |||
| "$(inherited)", | |||
| "$(PROJECT_DIR)/Flutter", | |||
| ); | |||
| MARKETING_VERSION = 1.1.10; | |||
| MARKETING_VERSION = 1.1.11; | |||
| PRODUCT_BUNDLE_IDENTIFIER = vn.azteam.farmdemo; | |||
| PRODUCT_NAME = "$(TARGET_NAME)"; | |||
| PROVISIONING_PROFILE_SPECIFIER = ""; | |||
| @@ -10,6 +10,8 @@ class NotificationDTO { | |||
| String? sendDate; | |||
| int? isRead; | |||
| String? type; | |||
| String? activityTypeName; | |||
| int? activityTypeId; | |||
| NotificationDTO({ | |||
| this.id, | |||
| @@ -23,6 +25,8 @@ class NotificationDTO { | |||
| this.sendDate, | |||
| this.type, | |||
| this.isRead, | |||
| this.activityTypeName, | |||
| this.activityTypeId, | |||
| }); | |||
| NotificationDTO.clone(NotificationDTO noti) { | |||
| this.id = noti.id; | |||
| @@ -36,6 +40,8 @@ class NotificationDTO { | |||
| this.sendDate = noti.sendDate; | |||
| this.type = noti.type; | |||
| this.isRead = noti.isRead; | |||
| this.activityTypeName = noti.activityTypeName; | |||
| this.activityTypeId = noti.activityTypeId; | |||
| } | |||
| NotificationDTO.fromJson(Map<String, dynamic> json) { | |||
| id = json['id']; | |||
| @@ -49,6 +55,8 @@ class NotificationDTO { | |||
| sendDate = json['sendDate']; | |||
| type = json['type']; | |||
| isRead = json['isRead']; | |||
| activityTypeName = json['activityTypeName']; | |||
| activityTypeId = json['activityTypeId']; | |||
| } | |||
| Map<String, dynamic> toJson() { | |||
| @@ -64,6 +72,8 @@ class NotificationDTO { | |||
| data['sendDate'] = this.sendDate; | |||
| data['type'] = this.type; | |||
| data['isRead'] = this.isRead; | |||
| data['activityTypeName'] = this.activityTypeName; | |||
| data['activityTypeId'] = this.activityTypeId; | |||
| return data; | |||
| } | |||
| } | |||
| @@ -534,6 +534,18 @@ class Repository { | |||
| } | |||
| } | |||
| Future<List<Employee>> getEmployeesByCropId(int cropId) async { | |||
| try { | |||
| var url = '${ConstCommon.baseUrl}/api/get-all-users-by-crop-id/$cropId'; | |||
| var res = await dio.get( | |||
| url, | |||
| ); | |||
| return (res.data as List).map((e) => Employee.fromJson(e)).toList(); | |||
| } catch (e) { | |||
| rethrow; | |||
| } | |||
| } | |||
| Future<List<SupplyFilter>> getSuppliesFilter() async { | |||
| try { | |||
| var url = '${ConstCommon.baseUrl}/api/tb-supplies/by-login-info/'; | |||
| @@ -13,7 +13,9 @@ import 'package:flutter/material.dart'; | |||
| import 'package:flutter_bloc/flutter_bloc.dart'; | |||
| import 'package:farm_tpf/utils/formatter.dart'; | |||
| import 'package:flutter_svg/svg.dart'; | |||
| import 'package:get/get.dart'; | |||
| import '../actions/sc_action.dart'; | |||
| import 'bloc/noti_bloc.dart'; | |||
| class NotificationScreen extends StatefulWidget { | |||
| @@ -277,6 +279,15 @@ class ItemInfinityWidget extends StatelessWidget { | |||
| currentReachedMax: currentReachedMax); | |||
| } | |||
| }); | |||
| } else if (item.contents == 'ACTIVITY_CREATE' || item.contents == 'ACTIVITY_UPDATE') { | |||
| Get.to(ActionScreen( | |||
| isEdit: true, | |||
| cropId: item.tbCropId ?? -1, | |||
| activityId: item.externalId ?? -1, | |||
| activityType: item.activityTypeName ?? '', | |||
| title: 'ActionScreen', | |||
| idAction: item.activityTypeId ?? -1, | |||
| )); | |||
| } else {} | |||
| }), | |||
| Container(padding: EdgeInsets.only(left: 16, right: 16), child: DashLineWidget()) | |||
| @@ -101,30 +101,30 @@ class _CreateTaskPageState extends State<CreateTaskPage> { | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| // ItemColumnWidget( | |||
| // title: 'Giao việc cho', | |||
| // child: ValueListenableBuilder<String>( | |||
| // valueListenable: bloc.selectedPEmployee, | |||
| // builder: (context, selected, _) { | |||
| // return ValueListenableBuilder<List<ItemDropDown>>( | |||
| // valueListenable: bloc.employees, | |||
| // builder: (context, employees, _) { | |||
| // return DropdownBottomSheet( | |||
| // dataSources: employees, | |||
| // initValue: selected, | |||
| // onSelected: (val) { | |||
| // bloc.selectedPEmployee.value = val.key ?? ''; | |||
| // }, | |||
| // hint: 'Giao việc cho', | |||
| // ); | |||
| // }, | |||
| // ); | |||
| // }, | |||
| // ), | |||
| // ), | |||
| // const SizedBox( | |||
| // height: 8, | |||
| // ), | |||
| ItemColumnWidget( | |||
| title: 'Giao việc cho', | |||
| child: ValueListenableBuilder<String>( | |||
| valueListenable: bloc.selectedPEmployee, | |||
| builder: (context, selected, _) { | |||
| return ValueListenableBuilder<List<ItemDropDown>>( | |||
| valueListenable: bloc.employees, | |||
| builder: (context, employees, _) { | |||
| return DropdownBottomSheet( | |||
| dataSources: employees, | |||
| initValue: selected, | |||
| onSelected: (val) { | |||
| bloc.selectedPEmployee.value = val.key ?? ''; | |||
| }, | |||
| hint: 'Giao việc cho', | |||
| ); | |||
| }, | |||
| ); | |||
| }, | |||
| ), | |||
| ), | |||
| const SizedBox( | |||
| height: 8, | |||
| ), | |||
| ItemColumnWidget( | |||
| title: 'Tiêu đề', | |||
| child: TextFieldNormal( | |||
| @@ -22,9 +22,9 @@ class CreateTaskCubit extends Cubit<CreateTaskState> { | |||
| final detailCtl = TextEditingController(); | |||
| var deadline = ValueNotifier(DateTime.now()); | |||
| // var employeeRaws = <Employee>[]; | |||
| // var employees = ValueNotifier(<ItemDropDown>[]); | |||
| // var selectedPEmployee = ValueNotifier(''); | |||
| var employeeRaws = <Employee>[]; | |||
| var employees = ValueNotifier(<ItemDropDown>[]); | |||
| var selectedPEmployee = ValueNotifier(''); | |||
| // var existedCreateTask = UpdateCreateTask(); | |||
| void dispose() { | |||
| @@ -33,28 +33,28 @@ class CreateTaskCubit extends Cubit<CreateTaskState> { | |||
| } | |||
| Future<void> preparedData({required int cropId}) async { | |||
| // try { | |||
| // await Future.delayed(const Duration(seconds: 0)); | |||
| // emit(CreateTaskLoading()); | |||
| try { | |||
| await Future.delayed(const Duration(seconds: 0)); | |||
| emit(CreateTaskLoading()); | |||
| // employeeRaws = await repository.getEmployees(); | |||
| // employees.value = employeeRaws | |||
| // .map( | |||
| // (e) => ItemDropDown(key: e.id?.toString(), value: e.name), | |||
| // ) | |||
| // .toList(); | |||
| // emit(CreateTaskPrepareDataSuccessful()); | |||
| // } catch (e) { | |||
| // emit(CreateTaskFailure(AppException.handleError(e))); | |||
| // } | |||
| employeeRaws = await repository.getEmployeesByCropId(cropId); | |||
| employees.value = employeeRaws | |||
| .map( | |||
| (e) => ItemDropDown(key: e.id?.toString(), value: e.name), | |||
| ) | |||
| .toList(); | |||
| emit(CreateTaskPrepareDataSuccessful()); | |||
| } catch (e) { | |||
| emit(CreateTaskFailure(AppException.handleError(e))); | |||
| } | |||
| } | |||
| Future<void> onSubmit(int cropId) async { | |||
| if (formKey.currentState!.validate()) { | |||
| // if (selectedPEmployee.value.isEmpty) { | |||
| // Utils.showSnackBarWarning(message: 'Vui lòng chọn nhân viên'); | |||
| // return; | |||
| // } | |||
| if (selectedPEmployee.value.isEmpty) { | |||
| Utils.showSnackBarWarning(message: 'Vui lòng chọn nhân viên'); | |||
| return; | |||
| } | |||
| if (titleNameCtl.text.trim().isEmpty) { | |||
| Utils.showSnackBarWarning(message: 'Vui lòng nhập tiêu đề'); | |||
| return; | |||
| @@ -67,7 +67,7 @@ class CreateTaskCubit extends Cubit<CreateTaskState> { | |||
| ..cropId = cropId | |||
| ..title = titleNameCtl.text | |||
| ..detail = detailCtl.text | |||
| // ..userAssignedId = int.tryParse(selectedPEmployee.value) | |||
| ..userAssignedId = int.tryParse(selectedPEmployee.value) | |||
| ..deadline = deadline.value.convertLocalDateTimeToStringUtcDateTime(); | |||
| print(requestTask.toJson()); | |||
| UtilWidget.showLoading(); | |||
| @@ -1,5 +1,6 @@ | |||
| import 'package:bloc/bloc.dart'; | |||
| import 'package:equatable/equatable.dart'; | |||
| import 'package:flutter/material.dart'; | |||
| import 'package:get/get.dart'; | |||
| import '../../../../data/api/app_exception.dart'; | |||
| @@ -14,12 +15,21 @@ part 'task_detail_state.dart'; | |||
| class TaskDetailCubit extends Cubit<TaskDetailState> { | |||
| final repository = Repository(); | |||
| TaskDetailCubit() : super(TaskDetailInitial()); | |||
| final detailCtl = TextEditingController(); | |||
| var isCompleted = ValueNotifier(true); | |||
| void dispose() { | |||
| detailCtl.dispose(); | |||
| } | |||
| Future<void> preparedData(int taskId) async { | |||
| try { | |||
| await Future.delayed(const Duration(seconds: 0)); | |||
| emit(TaskDetailLoading()); | |||
| var task = await repository.getTaskDetail(id: taskId); | |||
| detailCtl.text = task.completedDetail ?? ''; | |||
| isCompleted.value = task.isCompleted ?? false; | |||
| isCompleted.notifyListeners(); | |||
| emit(TaskDetailSuccessful(task)); | |||
| } catch (e) { | |||
| emit(TaskDetailFailure(AppException.handleError(e))); | |||
| @@ -6,6 +6,7 @@ class Task { | |||
| String? executeDate; | |||
| Assigned? assigned; | |||
| bool? isCompleted; | |||
| String? completedDetail; | |||
| Task({ | |||
| this.id, | |||
| @@ -15,6 +16,7 @@ class Task { | |||
| this.executeDate, | |||
| this.assigned, | |||
| this.isCompleted, | |||
| this.completedDetail, | |||
| }); | |||
| Task.fromJson(Map<String, dynamic> json) { | |||
| @@ -25,6 +27,7 @@ class Task { | |||
| executeDate = json['completedAt']; | |||
| assigned = json['assigned'] != null ? new Assigned.fromJson(json['assigned']) : null; | |||
| isCompleted = json['completed']; | |||
| completedDetail = json['completedDetail']; | |||
| } | |||
| Map<String, dynamic> toJson() { | |||
| @@ -38,6 +41,7 @@ class Task { | |||
| data['assigned'] = this.assigned?.toJson(); | |||
| } | |||
| data['completed'] = this.isCompleted; | |||
| data['completedDetail'] = this.completedDetail; | |||
| return data; | |||
| } | |||
| @@ -49,6 +53,7 @@ class Task { | |||
| this.executeDate = task.executeDate; | |||
| this.assigned = task.assigned; | |||
| this.isCompleted = task.isCompleted; | |||
| this.completedDetail = task.completedDetail; | |||
| } | |||
| } | |||
| @@ -2,14 +2,14 @@ class RequestTask { | |||
| String? title; | |||
| String? detail; | |||
| String? deadline; | |||
| // int? userAssignedId; | |||
| int? userAssignedId; | |||
| int? cropId; | |||
| RequestTask({ | |||
| this.title, | |||
| this.detail, | |||
| this.deadline, | |||
| // this.userAssignedId, | |||
| this.userAssignedId, | |||
| this.cropId, | |||
| }); | |||
| @@ -17,7 +17,7 @@ class RequestTask { | |||
| title = json['title']; | |||
| detail = json['detail']; | |||
| deadline = json['deadline']; | |||
| // userAssignedId = json['user_assigned_id']; | |||
| userAssignedId = json['user_assigned_id']; | |||
| cropId = json['crop_id']; | |||
| } | |||
| @@ -26,7 +26,7 @@ class RequestTask { | |||
| data['title'] = this.title; | |||
| data['detail'] = this.detail; | |||
| data['deadline'] = this.deadline; | |||
| // data['user_assigned_id'] = this.userAssignedId; | |||
| data['user_assigned_id'] = this.userAssignedId; | |||
| data['crop_id'] = this.cropId; | |||
| return data; | |||
| } | |||
| @@ -1,18 +1,25 @@ | |||
| class RequestTaskUpdate { | |||
| int? id; | |||
| bool? completed; | |||
| String? completedDetail; | |||
| RequestTaskUpdate({this.id, this.completed}); | |||
| RequestTaskUpdate({ | |||
| this.id, | |||
| this.completed, | |||
| this.completedDetail, | |||
| }); | |||
| RequestTaskUpdate.fromJson(Map<String, dynamic> json) { | |||
| id = json['id']; | |||
| completed = json['completed']; | |||
| completedDetail = json['completedDetail']; | |||
| } | |||
| Map<String, dynamic> toJson() { | |||
| final Map<String, dynamic> data = new Map<String, dynamic>(); | |||
| data['id'] = this.id; | |||
| data['completed'] = this.completed; | |||
| data['completedDetail'] = this.completedDetail; | |||
| return data; | |||
| } | |||
| } | |||
| @@ -7,7 +7,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; | |||
| import '../../../themes/styles_text.dart'; | |||
| import '../../custom_widgets/app_bar_widget.dart'; | |||
| import '../../custom_widgets/button_widget.dart'; | |||
| import '../../custom_widgets/loading_list_page.dart'; | |||
| import '../../custom_widgets/textfield/text_field_normal.dart'; | |||
| import '../codes/widgets/item_column.dart'; | |||
| import 'cubit/task_detail_cubit.dart'; | |||
| import 'package:farm_tpf/utils/formatter.dart'; | |||
| @@ -21,12 +24,19 @@ class TaskDetailPage extends StatefulWidget { | |||
| class _TaskDetailPageState extends State<TaskDetailPage> { | |||
| var bloc = TaskDetailCubit(); | |||
| @override | |||
| void initState() { | |||
| super.initState(); | |||
| bloc.preparedData(widget.taskId); | |||
| } | |||
| @override | |||
| void dispose() { | |||
| bloc.dispose(); | |||
| super.dispose(); | |||
| } | |||
| @override | |||
| Widget build(BuildContext context) { | |||
| return Scaffold( | |||
| @@ -36,74 +46,104 @@ class _TaskDetailPageState extends State<TaskDetailPage> { | |||
| child: Column( | |||
| children: [ | |||
| Expanded( | |||
| child: BlocBuilder<TaskDetailCubit, TaskDetailState>( | |||
| bloc: bloc, | |||
| builder: (context, state) { | |||
| if (state is TaskDetailLoading) { | |||
| return Center( | |||
| child: LoadingListPage(), | |||
| ); | |||
| } else if (state is TaskDetailFailure) { | |||
| return Center(child: Text(state.errorMessage)); | |||
| } else if (state is TaskDetailSuccessful) { | |||
| return SingleChildScrollView( | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| _itemTaskDetail( | |||
| title: '', | |||
| detail: state.task.title ?? '', | |||
| titleStyle: StylesText.body1, | |||
| detailStyle: StylesText.body1.copyWith( | |||
| color: Colors.blue, | |||
| child: BlocBuilder<TaskDetailCubit, TaskDetailState>( | |||
| bloc: bloc, | |||
| builder: (context, state) { | |||
| if (state is TaskDetailLoading) { | |||
| return Center( | |||
| child: LoadingListPage(), | |||
| ); | |||
| } else if (state is TaskDetailFailure) { | |||
| return Center(child: Text(state.errorMessage)); | |||
| } else if (state is TaskDetailSuccessful) { | |||
| return SingleChildScrollView( | |||
| child: Column( | |||
| crossAxisAlignment: CrossAxisAlignment.start, | |||
| children: [ | |||
| _itemTaskDetail( | |||
| title: '', | |||
| detail: state.task.title ?? '', | |||
| titleStyle: StylesText.body1, | |||
| detailStyle: StylesText.body1.copyWith( | |||
| color: Colors.blue, | |||
| ), | |||
| ), | |||
| ), | |||
| _itemTaskDetail(title: 'Hạn chót : ', detail: state.task.dueDate?.format_DDMMYY().toString() ?? ''), | |||
| (state.task.isCompleted ?? false) | |||
| ? _itemTaskDetail(title: 'Thời gian hoàn thành : ', detail: state.task.executeDate?.format_DDMMYY().toString() ?? '') | |||
| : const SizedBox.shrink(), | |||
| Padding( | |||
| padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), | |||
| child: Text( | |||
| 'Mô tả công việc :', | |||
| style: StylesText.body2, | |||
| _itemTaskDetail(title: 'Hạn chót : ', detail: state.task.dueDate?.format_DDMMYY().toString() ?? ''), | |||
| (state.task.isCompleted ?? false) | |||
| ? _itemTaskDetail(title: 'Thời gian hoàn thành : ', detail: state.task.executeDate?.format_DDMMYY().toString() ?? '') | |||
| : const SizedBox.shrink(), | |||
| Padding( | |||
| padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), | |||
| child: Text( | |||
| 'Mô tả công việc :', | |||
| style: StylesText.body2, | |||
| ), | |||
| ), | |||
| ), | |||
| Padding( | |||
| padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), | |||
| child: Text( | |||
| state.task.description ?? '', | |||
| style: StylesText.body3, | |||
| Padding( | |||
| padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), | |||
| child: Text( | |||
| state.task.description ?? '', | |||
| style: StylesText.body3, | |||
| ), | |||
| ), | |||
| ), | |||
| Padding( | |||
| padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), | |||
| child: CheckboxWidget( | |||
| title: 'Hoàn thành', | |||
| style: StylesText.body2, | |||
| isChecked: state.task.isCompleted ?? false, | |||
| onChange: (status) { | |||
| bloc.updateStatusTask( | |||
| RequestTaskUpdate( | |||
| id: widget.taskId, | |||
| completed: status, | |||
| ValueListenableBuilder<bool>( | |||
| valueListenable: bloc.isCompleted, | |||
| builder: (context, isCompleted, _) { | |||
| return Padding( | |||
| padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), | |||
| child: CheckboxWidget( | |||
| title: 'Hoàn thành', | |||
| style: StylesText.body2, | |||
| isChecked: isCompleted, | |||
| onChange: (status) { | |||
| bloc.isCompleted.value = status; | |||
| // bloc.updateStatusTask( | |||
| // RequestTaskUpdate( | |||
| // id: widget.taskId, | |||
| // completed: status, | |||
| // ), | |||
| // ); | |||
| }, | |||
| ), | |||
| ); | |||
| }, | |||
| ), | |||
| ), | |||
| const SizedBox( | |||
| height: 8, | |||
| ), | |||
| ], | |||
| const SizedBox( | |||
| height: 8, | |||
| ), | |||
| ItemColumnWidget( | |||
| title: 'Mô tả công việc', | |||
| child: TextFieldNormal( | |||
| controller: bloc.detailCtl, | |||
| maxLines: 3, | |||
| hint: 'Mô tả', | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| ); | |||
| } | |||
| return const SizedBox.shrink(); | |||
| }, | |||
| ), | |||
| ), | |||
| // const SizedBox( | |||
| // height: 8, | |||
| // ), | |||
| Padding( | |||
| padding: const EdgeInsets.all(8.0), | |||
| child: ButtonWidget( | |||
| title: 'Cập nhật hoàn thành', | |||
| onPressed: () { | |||
| bloc.updateStatusTask( | |||
| RequestTaskUpdate( | |||
| id: widget.taskId, | |||
| completed: bloc.isCompleted.value, | |||
| completedDetail: bloc.detailCtl.text, | |||
| ), | |||
| ); | |||
| } | |||
| return const SizedBox.shrink(); | |||
| }, | |||
| )), | |||
| const SizedBox( | |||
| height: 8, | |||
| }, | |||
| ), | |||
| ), | |||
| ], | |||
| ), | |||
| @@ -2,7 +2,7 @@ name: farm_tpf | |||
| description: A new Flutter project. | |||
| publish_to: 'none' | |||
| version: 1.1.10+29 | |||
| version: 1.1.11+30 | |||
| environment: | |||
| sdk: ">=3.0.0 <4.0.0" | |||