test: add unit tests for poultry repository and searchable dropdown functionalities

- Introduced tests for `PoultryScienceRepositoryImp` to validate delegated remote calls.
- Added comprehensive tests for `SearchableDropdownLogic` covering selection, overlay, and search logic.
- Enhanced `SearchableDropdown` widget tests for multi-select, label building, and overlay management.
This commit is contained in:
2025-11-16 15:40:21 +03:30
parent 716a7ed259
commit a66c8b69ca
16 changed files with 812 additions and 304 deletions

View File

@@ -1,4 +1,4 @@
sdk.dir=C:\\Users\\Housh11\\AppData\\Local\\Android\\sdk sdk.dir=C:/Users/Housh11/AppData/Local/Android/Sdk
flutter.sdk=C:\\src\\flutter flutter.sdk=C:\\src\\flutter
flutter.buildMode=debug flutter.buildMode=debug
flutter.versionName=1.3.32 flutter.versionName=1.3.32

View File

@@ -12,17 +12,6 @@ class SystemDesignPage extends StatefulWidget {
class _SystemDesignPageState extends State<SystemDesignPage> { class _SystemDesignPageState extends State<SystemDesignPage> {
List<bool> _isOpen = [false, false, false, false, false, false]; List<bool> _isOpen = [false, false, false, false, false, false];
void _handleAdd() {
print("Add FAB pressed");
}
void _handleEdit() {
print("Edit FAB pressed");
}
void _handleDelete() {
print("Delete FAB pressed");
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -4,7 +4,7 @@ import 'package:rasadyar_core/core.dart';
import 'logic.dart'; import 'logic.dart';
class TestPage extends StatelessWidget { class TestPage extends StatelessWidget {
const TestPage({Key? key}) : super(key: key); const TestPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -5,7 +5,7 @@ import 'package:rasadyar_chicken/presentation/utils/nested_keys_utils.dart';
import 'package:rasadyar_chicken/presentation/utils/string_utils.dart'; import 'package:rasadyar_chicken/presentation/utils/string_utils.dart';
import 'package:rasadyar_chicken/presentation/widget/base_page/view.dart'; import 'package:rasadyar_chicken/presentation/widget/base_page/view.dart';
import 'package:rasadyar_chicken/presentation/widget/steward/inventory_widget.dart'; import 'package:rasadyar_chicken/presentation/widget/steward/inventory_widget.dart';
import 'package:rasadyar_core/core.dart'; import 'package:rasadyar_core/core.dart' hide modalDatePicker;
import 'logic.dart'; import 'logic.dart';
@@ -412,12 +412,14 @@ class SalesInProvincePage extends GetView<SalesInProvinceLogic> {
children: [ children: [
Expanded( Expanded(
child: timeFilterWidget( child: timeFilterWidget(
controller: controller,
date: controller.fromDateFilter, date: controller.fromDateFilter,
onChanged: (jalali) => controller.fromDateFilter.value = jalali, onChanged: (jalali) => controller.fromDateFilter.value = jalali,
), ),
), ),
Expanded( Expanded(
child: timeFilterWidget( child: timeFilterWidget(
controller: controller,
isFrom: false, isFrom: false,
date: controller.toDateFilter, date: controller.toDateFilter,
onChanged: (jalali) => controller.toDateFilter.value = jalali, onChanged: (jalali) => controller.toDateFilter.value = jalali,
@@ -441,6 +443,7 @@ class SalesInProvincePage extends GetView<SalesInProvinceLogic> {
} }
GestureDetector timeFilterWidget({ GestureDetector timeFilterWidget({
required SalesInProvinceLogic controller,
isFrom = true, isFrom = true,
required Rx<Jalali> date, required Rx<Jalali> date,
required Function(Jalali jalali) onChanged, required Function(Jalali jalali) onChanged,
@@ -482,139 +485,4 @@ class SalesInProvincePage extends GetView<SalesInProvinceLogic> {
), ),
); );
} }
Container modalDatePicker(ValueChanged<Jalali> onDateSelected) {
Jalali? tempPickedDate;
return Container(
height: 250,
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
child: Row(
children: [
SizedBox(width: 20),
RElevated(
height: 35,
width: 70,
textStyle: AppFonts.yekan14.copyWith(color: Colors.white),
onPressed: () {
onDateSelected(tempPickedDate ?? Jalali.now());
Get.back();
},
text: 'تایید',
),
Spacer(),
RElevated(
height: 35,
width: 70,
backgroundColor: AppColor.error,
textStyle: AppFonts.yekan14.copyWith(color: Colors.white),
onPressed: () {
onDateSelected(tempPickedDate ?? Jalali.now());
Get.back();
},
text: 'لغو',
),
SizedBox(width: 20),
],
),
),
Divider(height: 0, thickness: 1),
Expanded(
child: Container(
child: PersianCupertinoDatePicker(
initialDateTime: Jalali.now(),
mode: PersianCupertinoDatePickerMode.date,
onDateTimeChanged: (dateTime) {
tempPickedDate = dateTime;
},
),
),
),
],
),
);
}
Widget show2StepAddBottomSheet() {
return BaseBottomSheet(
height: Get.height * .39,
child: Column(
spacing: 8,
children: [
buildRow(
title: 'تاریخ ثبت',
value: controller.tmpStewardAllocation?.date?.formattedJalaliDate ?? 'ندارد',
),
buildRow(
title: 'نام و نام خانوادگی خریدار',
value:
controller.guildsModel
.firstWhere((p0) => p0.key == controller.tmpStewardAllocation?.guildKey)
.user
?.fullname ??
'ندارد',
),
buildRow(
title: 'شماره خریدار',
value:
controller.guildsModel
.firstWhere((p0) => p0.key == controller.tmpStewardAllocation?.guildKey)
.user
?.mobile ??
'ندارد',
),
buildRow(
title: 'قیمت هر کیلو',
value: '${controller.tmpStewardAllocation?.amount.separatedByCommaFa ?? 0} ریال ',
),
buildRow(
title: 'وزن تخصیصی',
value:
'${controller.tmpStewardAllocation?.weightOfCarcasses?.toInt().separatedByCommaFa ?? 0} کیلوگرم',
),
buildRow(
title: 'قیمت کل',
value: '${controller.tmpStewardAllocation?.totalAmount.separatedByCommaFa ?? 0} ریال',
),
Row(
spacing: 10,
children: [
Expanded(
child: RElevated(
backgroundColor: AppColor.greenNormal,
height: 40,
text: 'ثبت',
textStyle: AppFonts.yekan18.copyWith(color: Colors.white),
onPressed: () async {
await controller.submitAllocation();
Get
..back()
..back();
},
),
),
Expanded(
child: ROutlinedElevated(
height: 40,
borderColor: AppColor.error,
text: ' بازگشت',
textStyle: AppFonts.yekan18.copyWith(color: AppColor.error),
onPressed: () {
Get
..back()
..back();
},
),
),
],
),
],
),
);
}
} }

View File

@@ -31,15 +31,24 @@ Widget addOrEditBottomSheet(SalesInProvinceLogic controller, {bool isEditMode =
spacing: 12, spacing: 12,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
RTextField( ObxValue((data) {
controller: TextEditingController(), return RTextField(
filledColor: AppColor.bgLight, controller: TextEditingController(),
label: 'تاریخ', filledColor: AppColor.bgLight,
readonly: true, filled: true,
borderColor: AppColor.darkGreyLight, label: 'تاریخ',
initText: Jalali.now().formatCompactDate(), onTap: () {
), Get.bottomSheet(
modalDatePicker((value) {
controller.fromDateFilter.value = value;
controller.fromDateFilter.refresh();
}),
);
},
borderColor: AppColor.darkGreyLight,
initText: (data.value ?? Jalali.now()).formatCompactDate(),
);
}, controller.fromDateFilter),
Visibility( Visibility(
visible: isEditMode == false, visible: isEditMode == false,
child: Container( child: Container(
@@ -271,9 +280,11 @@ Widget addOrEditBottomSheet(SalesInProvinceLogic controller, {bool isEditMode =
onPressed: isEditMode onPressed: isEditMode
? () async { ? () async {
await controller.updateAllocation(); await controller.updateAllocation();
Get.back();
} }
: () async { : () async {
await controller.submitAllocation(); await controller.submitAllocation();
Get.back();
}, },
); );
}, controller.isValid), }, controller.isValid),
@@ -365,3 +376,140 @@ Widget productDropDown(SalesInProvinceLogic controller) {
); );
}); });
} }
Container modalDatePicker(ValueChanged<Jalali> onDateSelected) {
Jalali? tempPickedDate;
return Container(
height: 250,
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
child: Row(
children: [
SizedBox(width: 20),
RElevated(
height: 35,
width: 70,
textStyle: AppFonts.yekan14.copyWith(color: Colors.white),
onPressed: () {
onDateSelected(tempPickedDate ?? Jalali.now());
Get.back();
},
text: 'تایید',
),
Spacer(),
RElevated(
height: 35,
width: 70,
backgroundColor: AppColor.error,
textStyle: AppFonts.yekan14.copyWith(color: Colors.white),
onPressed: () {
onDateSelected(tempPickedDate ?? Jalali.now());
Get.back();
},
text: 'لغو',
),
SizedBox(width: 20),
],
),
),
Divider(height: 0, thickness: 1),
Expanded(
child: Container(
child: PersianCupertinoDatePicker(
initialDateTime: Jalali.now(),
minimumDate: Jalali.now().add(days: -1),
maximumDate: Jalali.now(),
mode: PersianCupertinoDatePickerMode.date,
onDateTimeChanged: (dateTime) {
tempPickedDate = dateTime;
},
),
),
),
],
),
);
}
Widget show2StepAddBottomSheet(SalesInProvinceLogic controller) {
return BaseBottomSheet(
height: Get.height * .39,
child: Column(
spacing: 8,
children: [
buildRow(
title: 'تاریخ ثبت',
value: controller.tmpStewardAllocation?.date?.formattedJalaliDate ?? 'ندارد',
),
buildRow(
title: 'نام و نام خانوادگی خریدار',
value:
controller.guildsModel
.firstWhere((p0) => p0.key == controller.tmpStewardAllocation?.guildKey)
.user
?.fullname ??
'ندارد',
),
buildRow(
title: 'شماره خریدار',
value:
controller.guildsModel
.firstWhere((p0) => p0.key == controller.tmpStewardAllocation?.guildKey)
.user
?.mobile ??
'ندارد',
),
buildRow(
title: 'قیمت هر کیلو',
value: '${controller.tmpStewardAllocation?.amount.separatedByCommaFa ?? 0} ریال ',
),
buildRow(
title: 'وزن تخصیصی',
value:
'${controller.tmpStewardAllocation?.weightOfCarcasses?.toInt().separatedByCommaFa ?? 0} کیلوگرم',
),
buildRow(
title: 'قیمت کل',
value: '${controller.tmpStewardAllocation?.totalAmount.separatedByCommaFa ?? 0} ریال',
),
Row(
spacing: 10,
children: [
Expanded(
child: RElevated(
backgroundColor: AppColor.greenNormal,
height: 40,
text: 'ثبت',
textStyle: AppFonts.yekan18.copyWith(color: Colors.white),
onPressed: () async {
await controller.submitAllocation();
Get
..back()
..back();
},
),
),
Expanded(
child: ROutlinedElevated(
height: 40,
borderColor: AppColor.error,
text: ' بازگشت',
textStyle: AppFonts.yekan18.copyWith(color: AppColor.error),
onPressed: () {
Get
..back()
..back();
},
),
),
],
),
],
),
);
}

View File

@@ -357,4 +357,26 @@ class SalesOutOfProvinceLogic extends GetxController {
onError: (error, stacktrace) {}, onError: (error, stacktrace) {},
); );
} }
void setSaleDate(Jalali value) {
saleDate.value = value;
saleDate.refresh();
dateErrorDialog();
}
void setProductionDate(DayInfo value) {
productionDate.value = value.date;
remainingStock.value = value.remainingStock;
dateErrorDialog();
}
void dateErrorDialog() {
if ((productionDate.value?.distanceTo(saleDate.value) ?? 0) >= 1) {
saleDate.value = Jalali.now();
Future.delayed(
Duration(milliseconds: 300),
() => defaultShowErrorMessage("تاریخ تولید نمی تواند قبل از تاریخ فروش باشد"),
);
}
}
} }

View File

@@ -352,14 +352,21 @@ class SalesOutOfProvincePage extends GetView<SalesOutOfProvinceLogic> {
child: Column( child: Column(
spacing: 12, spacing: 12,
children: [ children: [
RTextField( ObxValue((data) {
controller: TextEditingController(), return RTextField(
filledColor: AppColor.bgLight, controller: TextEditingController(),
label: 'تاریخ', filledColor: AppColor.bgLight,
readonly: true, filled: true,
borderColor: AppColor.darkGreyLight, label: 'تاریخ',
initText: Jalali.now().formatCompactDate(), onTap: () {
), Get.bottomSheet(
modalDatePicker((value) => controller.setSaleDate(value)),
);
},
borderColor: AppColor.darkGreyLight,
initText: data.value.formatCompactDate(),
);
}, controller.saleDate),
Visibility( Visibility(
visible: isOnEdit == false, visible: isOnEdit == false,
child: Container( child: Container(
@@ -430,8 +437,7 @@ class SalesOutOfProvincePage extends GetView<SalesOutOfProvinceLogic> {
label: 'تاریخ تولید گوشت', label: 'تاریخ تولید گوشت',
selectedDate: controller.productionDate.value?.formatCompactDate(), selectedDate: controller.productionDate.value?.formatCompactDate(),
onDateSelect: (value) { onDateSelect: (value) {
controller.productionDate.value = value.date; controller.setProductionDate(value);
controller.remainingStock.value = value.remainingStock;
}, },
dayData: controller.quotaType.value == 1 dayData: controller.quotaType.value == 1
? controller.governmentalProductionDateData ? controller.governmentalProductionDateData

View File

@@ -323,7 +323,7 @@ void main() {
).thenAnswer((_) async => mockResponse); ).thenAnswer((_) async => mockResponse);
// Act // Act
await authRemoteDataSource.submitUserInfo(userInfo); await authRemoteDataSource.stewardAppLogin(token: 'test-token', queryParameters: userInfo);
// Assert // Assert
verify( verify(

View File

@@ -146,45 +146,6 @@ void main() {
}); });
}); });
group('submitUserInfo', () { // submitUserInfo removed from API; covered by stewardAppLogin at remote layer
test(
'should call remote submitUserInfo with correct parameters',
() async {
// Arrange
const phone = '09123456789';
const deviceName = 'Test Device';
final expectedData = {'mobile': phone, 'device_name': deviceName};
when(
() => mockAuthRemote.submitUserInfo(any()),
).thenAnswer((_) async {});
// Act
await authRepository.submitUserInfo(
phone: phone,
deviceName: deviceName,
);
// Assert
verify(() => mockAuthRemote.submitUserInfo(expectedData)).called(1);
},
);
test('should call remote submitUserInfo without device name', () async {
// Arrange
const phone = '09123456789';
final expectedData = {'mobile': phone, 'device_name': null};
when(
() => mockAuthRemote.submitUserInfo(any()),
).thenAnswer((_) async {});
// Act
await authRepository.submitUserInfo(phone: phone);
// Assert
verify(() => mockAuthRemote.submitUserInfo(expectedData)).called(1);
});
});
}); });
} }

View File

@@ -0,0 +1,145 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:rasadyar_chicken/data/data_source/remote/poultry_science/poultry_science_remote.dart';
import 'package:rasadyar_chicken/data/models/request/kill_registration/kill_registration.dart';
import 'package:rasadyar_chicken/data/repositories/poultry_science/poultry_science_repository_imp.dart';
import 'package:rasadyar_core/core.dart';
class _MockRemote extends Mock implements PoultryScienceRemoteDatasource {}
void main() {
setUpAll(() {
registerFallbackValue(FormData());
registerFallbackValue(const KillRegistrationRequest());
registerFallbackValue(<String, dynamic>{});
});
group('PoultryScienceRepositoryImp', () {
late _MockRemote remote;
late PoultryScienceRepositoryImp repo;
setUp(() {
remote = _MockRemote();
repo = PoultryScienceRepositoryImp(remote);
});
test('getHomePoultry delegates', () async {
when(() => remote.getHomePoultryScience(token: any(named: 'token'), type: any(named: 'type')))
.thenAnswer((_) async => null);
final res = await repo.getHomePoultry(token: 't', type: 'x');
expect(res, null);
verify(() => remote.getHomePoultryScience(token: 't', type: 'x')).called(1);
});
test('getHatchingPoultry delegates', () async {
when(() => remote.getHatchingPoultry(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getHatchingPoultry(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getHatchingPoultry(token: 't', queryParameters: {'a': 1})).called(1);
});
test('submitPoultryScienceReport delegates', () async {
when(() => remote.submitPoultryScienceReport(token: any(named: 'token'), data: any(named: 'data'), onSendProgress: any(named: 'onSendProgress')))
.thenAnswer((_) async {});
await repo.submitPoultryScienceReport(token: 't', data: FormData());
verify(() => remote.submitPoultryScienceReport(token: 't', data: any(named: 'data'), onSendProgress: any(named: 'onSendProgress'))).called(1);
});
test('getHatchingPoultryReport delegates', () async {
when(() => remote.getPoultryScienceReport(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getHatchingPoultryReport(token: 't', queryParameters: {'q': 1});
expect(res, null);
verify(() => remote.getPoultryScienceReport(token: 't', queryParameters: {'q': 1})).called(1);
});
test('getPoultryScienceFarmList delegates', () async {
when(() => remote.getPoultryScienceFarmList(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getPoultryScienceFarmList(token: 't', queryParameters: {'p': 1});
expect(res, null);
verify(() => remote.getPoultryScienceFarmList(token: 't', queryParameters: {'p': 1})).called(1);
});
test('getApprovedPrice delegates', () async {
when(() => remote.getApprovedPrice(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getApprovedPrice(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getApprovedPrice(token: 't', queryParameters: {'a': 1})).called(1);
});
test('getAllPoultry delegates', () async {
when(() => remote.getAllPoultry(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getAllPoultry(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getAllPoultry(token: 't', queryParameters: {'a': 1})).called(1);
});
test('getSellForFreezing delegates', () async {
when(() => remote.getSellForFreezing(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getSellForFreezing(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getSellForFreezing(token: 't', queryParameters: {'a': 1})).called(1);
});
test('getPoultryExport delegates', () async {
when(() => remote.getPoultryExport(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getPoultryExport(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getPoultryExport(token: 't', queryParameters: {'a': 1})).called(1);
});
test('getUserPoultry delegates', () async {
when(() => remote.getUserPoultry(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getUserPoultry(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getUserPoultry(token: 't', queryParameters: {'a': 1})).called(1);
});
test('getPoultryHatching delegates', () async {
when(() => remote.getPoultryHatching(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getPoultryHatching(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getPoultryHatching(token: 't', queryParameters: {'a': 1})).called(1);
});
test('getKillHouseList delegates', () async {
when(() => remote.getKillHouseList(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getKillHouseList(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getKillHouseList(token: 't', queryParameters: {'a': 1})).called(1);
});
test('submitKillRegistration delegates', () async {
when(() => remote.submitKillRegistration(token: any(named: 'token'), request: any(named: 'request')))
.thenAnswer((_) async {});
await repo.submitKillRegistration(token: 't', request: const KillRegistrationRequest());
verify(() => remote.submitKillRegistration(token: 't', request: any(named: 'request'))).called(1);
});
test('getPoultryOderList delegates', () async {
when(() => remote.getPoultryOderList(token: any(named: 'token'), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => null);
final res = await repo.getPoultryOderList(token: 't', queryParameters: {'a': 1});
expect(res, null);
verify(() => remote.getPoultryOderList(token: 't', queryParameters: {'a': 1})).called(1);
});
test('deletePoultryOder delegates', () async {
when(() => remote.deletePoultryOder(token: any(named: 'token'), orderId: any(named: 'orderId')))
.thenAnswer((_) async {});
await repo.deletePoultryOder(token: 't', orderId: 'id');
verify(() => remote.deletePoultryOder(token: 't', orderId: 'id')).called(1);
});
});
}

View File

@@ -4,7 +4,6 @@ import 'package:rasadyar_chicken/data/data_source/remote/auth/auth_remote.dart';
import 'package:rasadyar_chicken/data/models/response/user_info/user_info_model.dart'; import 'package:rasadyar_chicken/data/models/response/user_info/user_info_model.dart';
import 'package:rasadyar_chicken/data/models/response/user_profile_model/user_profile_model.dart'; import 'package:rasadyar_chicken/data/models/response/user_profile_model/user_profile_model.dart';
import 'package:rasadyar_chicken/data/repositories/auth/auth_repository_imp.dart'; import 'package:rasadyar_chicken/data/repositories/auth/auth_repository_imp.dart';
import 'package:rasadyar_core/core.dart';
class MockAuthRemoteDataSource extends Mock implements AuthRemoteDataSource {} class MockAuthRemoteDataSource extends Mock implements AuthRemoteDataSource {}
@@ -22,7 +21,6 @@ void main() {
test('should complete full login flow successfully', () async { test('should complete full login flow successfully', () async {
// Arrange // Arrange
const phoneNumber = '09123456789'; const phoneNumber = '09123456789';
const deviceName = 'Test Device';
final authRequest = { final authRequest = {
'username': 'test@example.com', 'username': 'test@example.com',
'password': 'password', 'password': 'password',
@@ -48,28 +46,18 @@ void main() {
// Mock the flow // Mock the flow
when( when(
() => mockAuthRemote.getUserInfo(phoneNumber), () => mockAuthRemote.getUserInfo(phoneNumber),
).thenAnswer((_) async => expectedUserInfo); ).thenAnswer((_) async => expectedUserInfo);
when( when(
() => mockAuthRemote.submitUserInfo(any()), () => mockAuthRemote.login(authRequest: authRequest),
).thenAnswer((_) async {});
when(
() => mockAuthRemote.login(authRequest: authRequest),
).thenAnswer((_) async => expectedUserProfile); ).thenAnswer((_) async => expectedUserProfile);
// Act - Step 1: Get user info // Act - Step 1: Get user info
final userInfo = await authRepository.getUserInfo(phoneNumber); final userInfo = await authRepository.getUserInfo(phoneNumber);
expect(userInfo, equals(expectedUserInfo)); expect(userInfo, equals(expectedUserInfo));
// Act - Step 2: Submit user info // Act - Step 2: Login
await authRepository.submitUserInfo(
phone: phoneNumber,
deviceName: deviceName,
);
// Act - Step 3: Login
final userProfile = await authRepository.login( final userProfile = await authRepository.login(
authRequest: authRequest, authRequest: authRequest,
); );
@@ -77,7 +65,6 @@ void main() {
// Assert // Assert
expect(userProfile, equals(expectedUserProfile)); expect(userProfile, equals(expectedUserProfile));
verify(() => mockAuthRemote.getUserInfo(phoneNumber)).called(1); verify(() => mockAuthRemote.getUserInfo(phoneNumber)).called(1);
//verify(() => mockAuthRemote.submitUserInfo(any())).called(1);
verify(() => mockAuthRemote.login(authRequest: authRequest)).called(1); verify(() => mockAuthRemote.login(authRequest: authRequest)).called(1);
}); });
@@ -100,11 +87,11 @@ void main() {
// Mock the flow // Mock the flow
when( when(
() => mockAuthRemote.hasAuthenticated(), () => mockAuthRemote.hasAuthenticated(),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
when( when(
() => mockAuthRemote.login(authRequest: authRequest), () => mockAuthRemote.login(authRequest: authRequest),
).thenAnswer((_) async => expectedUserProfile); ).thenAnswer((_) async => expectedUserProfile);
// Act - Step 1: Check authentication status // Act - Step 1: Check authentication status
@@ -129,7 +116,7 @@ void main() {
const phoneNumber = '09123456789'; const phoneNumber = '09123456789';
when( when(
() => mockAuthRemote.getUserInfo(phoneNumber), () => mockAuthRemote.getUserInfo(phoneNumber),
).thenAnswer((_) async => null); ).thenAnswer((_) async => null);
// Act // Act
@@ -143,7 +130,6 @@ void main() {
test('should handle login failure after successful user info', () async { test('should handle login failure after successful user info', () async {
// Arrange // Arrange
const phoneNumber = '09123456789'; const phoneNumber = '09123456789';
const deviceName = 'Test Device';
final authRequest = { final authRequest = {
'username': 'test@example.com', 'username': 'test@example.com',
'password': 'wrong', 'password': 'wrong',
@@ -158,28 +144,18 @@ void main() {
// Mock the flow // Mock the flow
when( when(
() => mockAuthRemote.getUserInfo(phoneNumber), () => mockAuthRemote.getUserInfo(phoneNumber),
).thenAnswer((_) async => expectedUserInfo); ).thenAnswer((_) async => expectedUserInfo);
when( when(
() => mockAuthRemote.submitUserInfo(any()), () => mockAuthRemote.login(authRequest: authRequest),
).thenAnswer((_) async {});
when(
() => mockAuthRemote.login(authRequest: authRequest),
).thenAnswer((_) async => null); ).thenAnswer((_) async => null);
// Act - Step 1: Get user info (success) // Act - Step 1: Get user info (success)
final userInfo = await authRepository.getUserInfo(phoneNumber); final userInfo = await authRepository.getUserInfo(phoneNumber);
expect(userInfo, equals(expectedUserInfo)); expect(userInfo, equals(expectedUserInfo));
// Act - Step 2: Submit user info (success) // Act - Step 2: Login (failure)
await authRepository.submitUserInfo(
phone: phoneNumber,
deviceName: deviceName,
);
// Act - Step 3: Login (failure)
final userProfile = await authRepository.login( final userProfile = await authRepository.login(
authRequest: authRequest, authRequest: authRequest,
); );
@@ -187,7 +163,6 @@ void main() {
// Assert // Assert
expect(userProfile, isNull); expect(userProfile, isNull);
verify(() => mockAuthRemote.getUserInfo(phoneNumber)).called(1); verify(() => mockAuthRemote.getUserInfo(phoneNumber)).called(1);
verify(() => mockAuthRemote.submitUserInfo(any())).called(1);
verify(() => mockAuthRemote.login(authRequest: authRequest)).called(1); verify(() => mockAuthRemote.login(authRequest: authRequest)).called(1);
}); });
}); });
@@ -209,7 +184,7 @@ void main() {
test('should track authentication state correctly', () async { test('should track authentication state correctly', () async {
// Arrange // Arrange
when( when(
() => mockAuthRemote.hasAuthenticated(), () => mockAuthRemote.hasAuthenticated(),
).thenAnswer((_) async => true); ).thenAnswer((_) async => true);
// Act // Act
@@ -223,7 +198,7 @@ void main() {
test('should handle authentication state changes', () async { test('should handle authentication state changes', () async {
// Arrange // Arrange
when( when(
() => mockAuthRemote.hasAuthenticated(), () => mockAuthRemote.hasAuthenticated(),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
// Act // Act
@@ -236,40 +211,23 @@ void main() {
}); });
group('User Info Management', () { group('User Info Management', () {
test('should handle user info submission without device name', () async { test('should get user info by phone', () async {
// Arrange // Arrange
const phone = '09123456789'; const phone = '09123456789';
final expectedData = {'mobile': phone, 'device_name': null}; final expectedUserInfo = UserInfoModel(
isUser: true,
when( address: 'Test Address',
() => mockAuthRemote.submitUserInfo(any()), backend: 'test-backend',
).thenAnswer((_) async {}); apiKey: 'test-api-key',
// Act
await authRepository.submitUserInfo(phone: phone);
// Assert
verify(() => mockAuthRemote.submitUserInfo(expectedData)).called(1);
});
test('should handle user info submission with device name', () async {
// Arrange
const phone = '09123456789';
const deviceName = 'Test Device';
final expectedData = {'mobile': phone, 'device_name': deviceName};
when(
() => mockAuthRemote.submitUserInfo(any()),
).thenAnswer((_) async {});
// Act
await authRepository.submitUserInfo(
phone: phone,
deviceName: deviceName,
); );
when(
() => mockAuthRemote.getUserInfo(phone),
).thenAnswer((_) async => expectedUserInfo);
// Act
final res = await authRepository.getUserInfo(phone);
// Assert // Assert
verify(() => mockAuthRemote.submitUserInfo(expectedData)).called(1); expect(res, expectedUserInfo);
verify(() => mockAuthRemote.getUserInfo(phone)).called(1);
}); });
}); });
}); });

View File

@@ -58,7 +58,8 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
@override @override
void didUpdateWidget(MonthlyDataCalendar oldWidget) { void didUpdateWidget(MonthlyDataCalendar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.selectedDate != widget.selectedDate || oldWidget.dayData != widget.dayData) { if (oldWidget.selectedDate != widget.selectedDate ||
oldWidget.dayData != widget.dayData) {
_generateCalendar(); _generateCalendar();
_updateDisplayValue(); _updateDisplayValue();
} }
@@ -79,25 +80,31 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
final twoDaysAgoStr = twoDaysAgo.formatCompactDate(); final twoDaysAgoStr = twoDaysAgo.formatCompactDate();
final oneDayAgoStr = oneDayAgo.formatCompactDate(); final oneDayAgoStr = oneDayAgo.formatCompactDate();
return dateStr == todayStr || dateStr == twoDaysAgoStr || dateStr == oneDayAgoStr; return dateStr == todayStr ||
dateStr == twoDaysAgoStr ||
dateStr == oneDayAgoStr;
} }
void _generateCalendar() { void _generateCalendar() {
final days = _computeCalendarDays();
setState(() {
_calendarDays = days;
});
}
List<DayInfo?> _computeCalendarDays() {
final days = <DayInfo?>[]; final days = <DayInfo?>[];
final year = _currentMonth.year; final year = _currentMonth.year;
final month = _currentMonth.month; final month = _currentMonth.month;
final daysInMonth = _currentMonth.monthLength; final daysInMonth = _currentMonth.monthLength;
// Get first day of month to determine starting position
final firstDayOfMonth = Jalali(year, month, 1); final firstDayOfMonth = Jalali(year, month, 1);
final dayOfWeek = firstDayOfMonth.weekDay; // 1 = Saturday in shamsi_date final dayOfWeek = firstDayOfMonth.weekDay;
// Add empty cells for days before the first day of month
for (int i = 1; i < dayOfWeek; i++) { for (int i = 1; i < dayOfWeek; i++) {
days.add(null); days.add(null);
} }
// Add all days of the month
for (int day = 1; day <= daysInMonth; day++) { for (int day = 1; day <= daysInMonth; day++) {
final date = Jalali(year, month, day); final date = Jalali(year, month, day);
final today = Jalali.now(); final today = Jalali.now();
@@ -111,21 +118,23 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
date: date, date: date,
day: day, day: day,
formattedDate: formattedDate, formattedDate: formattedDate,
isToday: date.year == today.year && date.month == today.month && date.day == today.day, isToday:
date.year == today.year &&
date.month == today.month &&
date.day == today.day,
isEnabled: isEnabled, isEnabled: isEnabled,
hasZeroValue: hasZeroValue, hasZeroValue: hasZeroValue,
remainingStock: data?.value ?? 0, remainingStock: data?.value ?? 0,
), ),
); );
} }
return days;
setState(() {
_calendarDays = days;
});
} }
void _handleDayClick(DayInfo dayInfo) { void _handleDayClick(DayInfo dayInfo) {
if (dayInfo.isEnabled && !dayInfo.hasZeroValue && widget.onDateSelect != null) { if (dayInfo.isEnabled &&
!dayInfo.hasZeroValue &&
widget.onDateSelect != null) {
widget.onDateSelect!(dayInfo); widget.onDateSelect!(dayInfo);
Navigator.pop(context); Navigator.pop(context);
} }
@@ -176,7 +185,8 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
if (dayInfo != null) { if (dayInfo != null) {
final persianDay = _toPersianNumber(dayInfo.day); final persianDay = _toPersianNumber(dayInfo.day);
_textController.text = '$persianDay ${_monthNames[dayInfo.date.month - 1]}'; _textController.text =
'$persianDay ${_monthNames[dayInfo.date.month - 1]}';
} else { } else {
_textController.text = widget.selectedDate ?? ''; _textController.text = widget.selectedDate ?? '';
} }
@@ -198,23 +208,31 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Dialog( return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 650, maxHeight: 650), constraints: const BoxConstraints(maxWidth: 650, maxHeight: 650),
child: Card( child: Card(
elevation: 3, elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: StatefulBuilder(
mainAxisSize: MainAxisSize.min, builder: (context, setStateDialog) {
children: [ return Column(
_buildHeader(), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 16), children: [
_buildDayNamesHeader(), _buildHeaderWithDialogSet(setStateDialog),
const SizedBox(height: 8), const SizedBox(height: 16),
_buildCalendarGrid(), _buildDayNamesHeader(),
], const SizedBox(height: 8),
_buildCalendarGrid(),
],
);
},
), ),
), ),
), ),
@@ -224,6 +242,64 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
); );
} }
Widget _buildHeaderWithDialogSet(
void Function(void Function()) setStateDialog,
) {
return Container(
padding: const EdgeInsets.only(bottom: 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0), width: 2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: () {
setStateDialog(() {
if (_currentMonth.month == 1) {
_currentMonth = Jalali(_currentMonth.year - 1, 12, 1);
} else {
_currentMonth = Jalali(
_currentMonth.year,
_currentMonth.month - 1,
1,
);
}
_calendarDays = _computeCalendarDays();
});
},
icon: const Icon(Icons.chevron_left),
),
Text(
'${_monthNames[_currentMonth.month - 1]} ${_toPersianNumber(_currentMonth.year)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
color: Color(0xFF333333),
),
),
IconButton(
onPressed: () {
setStateDialog(() {
if (_currentMonth.month == 12) {
_currentMonth = Jalali(_currentMonth.year + 1, 1, 1);
} else {
_currentMonth = Jalali(
_currentMonth.year,
_currentMonth.month + 1,
1,
);
}
_calendarDays = _computeCalendarDays();
});
},
icon: const Icon(Icons.chevron_right),
),
],
),
);
}
Widget _buildHeader() { Widget _buildHeader() {
return Container( return Container(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
@@ -233,7 +309,10 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
IconButton(onPressed: _handlePrevMonth, icon: const Icon(Icons.chevron_right)), IconButton(
onPressed: _handlePrevMonth,
icon: const Icon(Icons.chevron_left),
),
Text( Text(
'${_monthNames[_currentMonth.month - 1]} ${_toPersianNumber(_currentMonth.year)}', '${_monthNames[_currentMonth.month - 1]} ${_toPersianNumber(_currentMonth.year)}',
style: const TextStyle( style: const TextStyle(
@@ -242,7 +321,10 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
color: Color(0xFF333333), color: Color(0xFF333333),
), ),
), ),
IconButton(onPressed: _handleNextMonth, icon: const Icon(Icons.chevron_left)), IconButton(
onPressed: _handleNextMonth,
icon: const Icon(Icons.chevron_right),
),
], ],
), ),
); );
@@ -333,7 +415,9 @@ class _MonthlyDataCalendarState extends State<MonthlyDataCalendar> {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
color: dayInfo.isToday ? const Color(0xFFFF9800) : const Color(0xFF333333), color: dayInfo.isToday
? const Color(0xFFFF9800)
: const Color(0xFF333333),
), ),
), ),
if (data != null && data.value != null) ...[ if (data != null && data.value != null) ...[

View File

@@ -96,7 +96,7 @@ class SearchableDropdownLogic<T> extends GetxController {
if (selectedItem != null) { if (selectedItem != null) {
this.selectedItem.value = selectedItem; this.selectedItem.value = selectedItem;
} else { } else {
this.selectedItem.value = initialValue != null ? [?initialValue] : []; this.selectedItem.value = initialValue != null ? [initialValue as T] : [];
} }
} }

View File

@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rasadyar_core/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown_logic.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SearchableDropdownLogic', () {
test('initializes selectedItem from provided list', () {
final logic = SearchableDropdownLogic<String>(
items: const ['A', 'B'],
selectedItem: const ['A'],
itemBuilder: (i) => Text(i),
);
expect(logic.selectedItem, ['A']);
});
test('initializes selectedItem from initialValue when single select', () {
final logic = SearchableDropdownLogic<String>(
items: const ['A', 'B'],
singleSelect: true,
initialValue: 'B',
itemBuilder: (i) => Text(i),
);
expect(logic.selectedItem, ['B']);
});
testWidgets('showOverlay sets isOpen and inserts overlay', (tester) async {
final logic = SearchableDropdownLogic<String>(
items: const ['A', 'B'],
itemBuilder: (i) => Text(i),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return CompositedTransformTarget(
link: logic.layerLink,
child: GestureDetector(
onTap: () => logic.showOverlay(context),
child: const SizedBox(width: 200, height: 40),
),
);
},
),
),
),
);
expect(logic.isOpen.value, false);
await tester.tap(find.byType(GestureDetector));
await tester.pump();
expect(logic.isOpen.value, true);
expect(find.text('نتیجه‌ای یافت نشد.'), findsNothing);
});
testWidgets('removeOverlay resets isOpen', (tester) async {
final logic = SearchableDropdownLogic<String>(
items: const ['A', 'B'],
itemBuilder: (i) => Text(i),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return CompositedTransformTarget(
link: logic.layerLink,
child: GestureDetector(
onTap: () {
if (!logic.isOpen.value) {
logic.showOverlay(context);
} else {
logic.removeOverlay();
}
},
child: const SizedBox(width: 200, height: 40),
),
);
},
),
),
),
);
await tester.tap(find.byType(GestureDetector));
await tester.pump();
expect(logic.isOpen.value, true);
await tester.tap(find.byType(GestureDetector));
await tester.pump();
expect(logic.isOpen.value, false);
});
testWidgets('tap item adds to selected and calls onChanged', (tester) async {
String? changed;
final logic = SearchableDropdownLogic<String>(
items: const ['A', 'B'],
onChanged: (s) => changed = s,
itemBuilder: (i) => Text(i),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return CompositedTransformTarget(
link: logic.layerLink,
child: GestureDetector(
onTap: () => logic.showOverlay(context),
child: const SizedBox(width: 200, height: 40),
),
);
},
),
),
),
);
await tester.tap(find.byType(GestureDetector));
await tester.pump();
await tester.tap(find.text('A'));
await tester.pump();
expect(logic.selectedItem.contains('A'), true);
expect(changed, 'A');
});
testWidgets('singleSelect updates searchController text using labelBuilder', (tester) async {
final logic = SearchableDropdownLogic<String>(
items: const ['A', 'B'],
singleSelect: true,
labelBuilder: (s) => 'L:$s',
itemBuilder: (i) => Text(i),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return CompositedTransformTarget(
link: logic.layerLink,
child: GestureDetector(
onTap: () => logic.showOverlay(context),
child: const SizedBox(width: 200, height: 40),
),
);
},
),
),
),
);
await tester.tap(find.byType(GestureDetector));
await tester.pump();
await tester.tap(find.text('B'));
await tester.pump();
expect(logic.searchController.text, 'L:B');
});
testWidgets('performSearch uses onSearch and updates filteredItems', (tester) async {
final logic = SearchableDropdownLogic<String>(
items: const ['A', 'B', 'C'],
onSearch: (q) async => ['B'],
itemBuilder: (i) => Text(i),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return CompositedTransformTarget(
link: logic.layerLink,
child: GestureDetector(
onTap: () => logic.showOverlay(context),
child: const SizedBox(width: 200, height: 40),
),
);
},
),
),
),
);
await tester.tap(find.byType(GestureDetector));
await tester.pump();
logic.performSearch('x');
await tester.pumpAndSettle();
expect(logic.filteredItems, ['B']);
});
});
}

View File

@@ -0,0 +1,139 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rasadyar_core/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SearchableDropdown widget', () {
testWidgets('asserts builders based on singleSelect', (tester) async {
expect(
() => SearchableDropdown<String>(
items: const ['A', 'B'],
itemBuilder: Text.new,
singleSelect: true,
),
throwsA(isA<AssertionError>()),
);
expect(
() => SearchableDropdown<String>(
items: const ['A', 'B'],
itemBuilder: Text.new,
singleSelect: false,
),
throwsA(isA<AssertionError>()),
);
});
testWidgets('single select: selecting item sets text and calls onChanged', (tester) async {
String? changed;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SearchableDropdown<String>(
items: const ['A', 'B'],
singleSelect: true,
singleLabelBuilder: (s) => 'L:$s',
itemBuilder: Text.new,
onChanged: (v) => changed = v,
),
),
),
);
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.tap(find.text('A'));
await tester.pump();
final tf = tester.widget<TextField>(find.byType(TextField));
expect(tf.controller!.text, 'L:A');
expect(changed, 'A');
});
testWidgets('multi select: shows chips and allows removing selection', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SearchableDropdown<String>(
items: const ['A', 'B', 'C'],
singleSelect: false,
multiLabelBuilder: (s) => Container(
key: Key('chip_${s ?? ''}'),
padding: const EdgeInsets.all(4),
child: Text(s ?? ''),
),
itemBuilder: Text.new,
),
),
),
);
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.tap(find.text('A'));
await tester.pump();
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.tap(find.text('B'));
await tester.pump();
expect(find.byKey(const Key('chip_A')), findsOneWidget);
expect(find.byKey(const Key('chip_B')), findsOneWidget);
await tester.tap(find.byKey(const Key('chip_A')));
await tester.pump();
expect(find.byKey(const Key('chip_A')), findsNothing);
expect(find.byKey(const Key('chip_B')), findsOneWidget);
});
testWidgets('readOnly toggles based on onSearch presence', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
SearchableDropdown<String>(
items: const ['A'],
singleSelect: true,
singleLabelBuilder: (s) => s ?? '',
itemBuilder: Text.new,
),
SearchableDropdown<String>(
items: const ['A'],
singleSelect: true,
singleLabelBuilder: (s) => s ?? '',
itemBuilder: Text.new,
onSearch: (q) async => ['A'],
),
],
),
),
),
);
final textFields = tester.widgetList<TextField>(find.byType(TextField)).toList();
expect(textFields[0].readOnly, true);
expect(textFields[1].readOnly, false);
});
testWidgets('tapping TextField toggles overlay list visibility', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SearchableDropdown<String>(
items: const ['A', 'B'],
singleSelect: true,
singleLabelBuilder: (s) => s ?? '',
itemBuilder: Text.new,
),
),
),
);
expect(find.text('A'), findsNothing);
await tester.tap(find.byType(TextField));
await tester.pump();
expect(find.text('A'), findsOneWidget);
await tester.tap(find.text('A'));
await tester.pump();
expect(find.text('A'), findsNothing);
});
});
}

View File

@@ -1,7 +1,7 @@
name: rasadyar_app name: rasadyar_app
description: "A new Flutter project." description: "A new Flutter project."
publish_to: 'none' publish_to: 'none'
version: 1.3.32+29 version: 1.3.33+30
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2