diff --git a/android/local.properties b/android/local.properties index a348d42..99787f3 100644 --- a/android/local.properties +++ b/android/local.properties @@ -1,5 +1,5 @@ -sdk.dir=C:/Users/Housh11/AppData/Local/Android/Sdk +sdk.dir=C:\\Users\\Housh11\\AppData\\Local\\Android\\sdk flutter.sdk=C:\\src\\flutter -flutter.buildMode=release +flutter.buildMode=debug flutter.versionName=1.3.32 flutter.versionCode=29 \ No newline at end of file diff --git a/packages/chicken/lib/data/data_source/remote/auth/auth_remote.dart b/packages/chicken/lib/data/data_source/remote/auth/auth_remote.dart index f9c6950..4878de0 100644 --- a/packages/chicken/lib/data/data_source/remote/auth/auth_remote.dart +++ b/packages/chicken/lib/data/data_source/remote/auth/auth_remote.dart @@ -11,8 +11,6 @@ abstract class AuthRemoteDataSource { Future getUserInfo(String phoneNumber); - Future submitUserInfo(Map userInfo); - - /// Calls `/steward-app-login/` endpoint with optional query parameters and required token header. + /// Calls `/steward-app-login/` endpoint with required token and `server` as query param, plus optional extra query parameters. Future stewardAppLogin({required String token, Map? queryParameters}); } diff --git a/packages/chicken/lib/data/data_source/remote/auth/auth_remote_imp.dart b/packages/chicken/lib/data/data_source/remote/auth/auth_remote_imp.dart index f04d286..6454fed 100644 --- a/packages/chicken/lib/data/data_source/remote/auth/auth_remote_imp.dart +++ b/packages/chicken/lib/data/data_source/remote/auth/auth_remote_imp.dart @@ -48,15 +48,6 @@ class AuthRemoteDataSourceImp extends AuthRemoteDataSource { return res.data; } - @override - Future submitUserInfo(Map userInfo) async { - await _httpClient.post( - '/steward-app-login/', - data: userInfo, - headers: {'Content-Type': 'application/json'}, - ); - } - @override Future stewardAppLogin({ required String token, @@ -64,7 +55,7 @@ class AuthRemoteDataSourceImp extends AuthRemoteDataSource { }) async { await _httpClient.post( '/steward-app-login/', - queryParameters: queryParameters, + data: queryParameters, headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'}, ); } diff --git a/packages/chicken/lib/data/repositories/auth/auth_repository.dart b/packages/chicken/lib/data/repositories/auth/auth_repository.dart index e0ea7d5..22d4599 100644 --- a/packages/chicken/lib/data/repositories/auth/auth_repository.dart +++ b/packages/chicken/lib/data/repositories/auth/auth_repository.dart @@ -10,8 +10,6 @@ abstract class AuthRepository { Future getUserInfo(String phoneNumber); - Future submitUserInfo({required String phone, String? deviceName}); - - /// Calls `/steward-app-login/` with Bearer token and optional query parameters. + /// Calls `/steward-app-login/` with Bearer token and required `server` query param. Future stewardAppLogin({required String token, Map? queryParameters}); } diff --git a/packages/chicken/lib/data/repositories/auth/auth_repository_imp.dart b/packages/chicken/lib/data/repositories/auth/auth_repository_imp.dart index d6883db..25992b2 100644 --- a/packages/chicken/lib/data/repositories/auth/auth_repository_imp.dart +++ b/packages/chicken/lib/data/repositories/auth/auth_repository_imp.dart @@ -23,12 +23,6 @@ class AuthRepositoryImpl implements AuthRepository { Future getUserInfo(String phoneNumber) async => await authRemote.getUserInfo(phoneNumber); - @override - Future submitUserInfo({required String phone, String? deviceName}) async { - var tmp = {'mobile': phone, 'device_name': deviceName}; - await authRemote.submitUserInfo(tmp); - } - @override Future stewardAppLogin({ required String token, diff --git a/packages/chicken/lib/presentation/pages/common/auth/logic.dart b/packages/chicken/lib/presentation/pages/common/auth/logic.dart index 18b0d11..feac081 100644 --- a/packages/chicken/lib/presentation/pages/common/auth/logic.dart +++ b/packages/chicken/lib/presentation/pages/common/auth/logic.dart @@ -131,12 +131,7 @@ class AuthLogic extends GetxController with GetTickerProviderStateMixin { ); } - authRepository.submitUserInfo( - phone: usernameController.value.text, - deviceName: deviceName.value, - ); - - authRepository.stewardAppLogin( + authTmp.stewardAppLogin( token: result?.accessToken ?? '', queryParameters: { "mobile": usernameController.value.text, diff --git a/packages/chicken/lib/presentation/pages/common/auth/view.dart b/packages/chicken/lib/presentation/pages/common/auth/view.dart index cb43da8..fae7a03 100644 --- a/packages/chicken/lib/presentation/pages/common/auth/view.dart +++ b/packages/chicken/lib/presentation/pages/common/auth/view.dart @@ -133,6 +133,7 @@ class AuthPage extends GetView { maxLines: 1, controller: controller.usernameController.value, keyboardType: TextInputType.number, + inputFormatters: [PersianFormatter()], initText: controller.usernameController.value.text, autofillHints: [AutofillHints.username], focusedBorder: OutlineInputBorder( @@ -187,6 +188,7 @@ class AuthPage extends GetView { autofillHints: [AutofillHints.password], variant: RTextFieldVariant.password, initText: passwordController.value.text, + inputFormatters: [PersianFormatter()], onChanged: (value) { passwordController.refresh(); }, diff --git a/packages/chicken/lib/presentation/pages/steward/home/view.dart b/packages/chicken/lib/presentation/pages/steward/home/view.dart index a451c90..9cff083 100644 --- a/packages/chicken/lib/presentation/pages/steward/home/view.dart +++ b/packages/chicken/lib/presentation/pages/steward/home/view.dart @@ -436,7 +436,6 @@ class HomePage extends GetView { Expanded( child: _informationLabelCard( title: 'مانده دولتی', - titleColor: AppColor.blueNormal, isLoading: data.value == null, description: data.value?.totalGovernmentalRemainWeight?.separatedByCommaFa ?? '0', iconPath: Assets.vec.cubeCardGovermentSvg.path, diff --git a/packages/chicken/lib/presentation/pages/steward/sales_in_province/widgets/cu_sale_in_provience.dart b/packages/chicken/lib/presentation/pages/steward/sales_in_province/widgets/cu_sale_in_provience.dart index 09bc2a5..9f4e261 100644 --- a/packages/chicken/lib/presentation/pages/steward/sales_in_province/widgets/cu_sale_in_provience.dart +++ b/packages/chicken/lib/presentation/pages/steward/sales_in_province/widgets/cu_sale_in_provience.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:rasadyar_chicken/data/models/response/guild/guild_model.dart'; import 'package:rasadyar_chicken/data/models/response/roles_products/roles_products.dart'; import 'package:rasadyar_chicken/presentation/pages/steward/sales_in_province/logic.dart'; import 'package:rasadyar_core/core.dart'; @@ -288,23 +287,49 @@ Widget addOrEditBottomSheet(SalesInProvinceLogic controller, {bool isEditMode = Widget guildsDropDown(SalesInProvinceLogic controller) { return Obx(() { final item = controller.selectedGuildModel.value; - return OverlayDropdownWidget( - key: ValueKey(item?.user?.fullname ?? ''), - items: controller.guildsModel, + + return SearchableDropdown( onChanged: (value) { controller.selectedGuildModel.value = value; }, - selectedItem: item, + selectedItem: [?item], + singleSelect: false, + items: controller.guildsModel, + hintText: 'انتخاب مباشر/صنف', itemBuilder: (item) => Text( item.user != null ? '${item.steward == true ? 'مباشر' : 'صنف'} ${item.user!.fullname} (${item.user!.mobile})' : 'بدون نام', ), - labelBuilder: (item) => Text( - item?.user != null - ? '${item?.steward == true ? 'مباشر' : 'صنف'} ${item?.user!.fullname} (${item?.user!.mobile})' - : 'انتخاب مباشر/صنف', + multiLabelBuilder: (item) => Container( + decoration: BoxDecoration( + color: AppColor.bgLight, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColor.darkGreyLight), + ), + padding: EdgeInsets.all(4), + child: Row( + children: [ + Text( + item?.user != null + ? '${item?.steward == true ? 'مباشر' : 'صنف'} ${item?.user!.fullname}' + : 'بدون نام', + style: AppFonts.yekan14, + ), + SizedBox(width: 4.w), + Icon(Icons.close, size: 16, color: AppColor.labelTextColor), + ], + ), ), + onSearch: (query) async { + return Future.microtask(() { + return RxList( + controller.guildsModel + .where((element) => element.user?.fullname?.contains(query) ?? false) + .toList(), + ); + }); + }, ); }); } diff --git a/packages/chicken/test/integration/auth_flow_integration_test.dart b/packages/chicken/test/integration/auth_flow_integration_test.dart index ac89dad..c617939 100644 --- a/packages/chicken/test/integration/auth_flow_integration_test.dart +++ b/packages/chicken/test/integration/auth_flow_integration_test.dart @@ -48,15 +48,15 @@ void main() { // Mock the flow when( - () => mockAuthRemote.getUserInfo(phoneNumber), + () => mockAuthRemote.getUserInfo(phoneNumber), ).thenAnswer((_) async => expectedUserInfo); when( - () => mockAuthRemote.submitUserInfo(any()), + () => mockAuthRemote.submitUserInfo(any()), ).thenAnswer((_) async {}); when( - () => mockAuthRemote.login(authRequest: authRequest), + () => mockAuthRemote.login(authRequest: authRequest), ).thenAnswer((_) async => expectedUserProfile); // Act - Step 1: Get user info @@ -77,7 +77,7 @@ void main() { // Assert expect(userProfile, equals(expectedUserProfile)); verify(() => mockAuthRemote.getUserInfo(phoneNumber)).called(1); - verify(() => mockAuthRemote.submitUserInfo(any())).called(1); + //verify(() => mockAuthRemote.submitUserInfo(any())).called(1); verify(() => mockAuthRemote.login(authRequest: authRequest)).called(1); }); @@ -100,11 +100,11 @@ void main() { // Mock the flow when( - () => mockAuthRemote.hasAuthenticated(), + () => mockAuthRemote.hasAuthenticated(), ).thenAnswer((_) async => false); when( - () => mockAuthRemote.login(authRequest: authRequest), + () => mockAuthRemote.login(authRequest: authRequest), ).thenAnswer((_) async => expectedUserProfile); // Act - Step 1: Check authentication status @@ -129,7 +129,7 @@ void main() { const phoneNumber = '09123456789'; when( - () => mockAuthRemote.getUserInfo(phoneNumber), + () => mockAuthRemote.getUserInfo(phoneNumber), ).thenAnswer((_) async => null); // Act @@ -158,15 +158,15 @@ void main() { // Mock the flow when( - () => mockAuthRemote.getUserInfo(phoneNumber), + () => mockAuthRemote.getUserInfo(phoneNumber), ).thenAnswer((_) async => expectedUserInfo); when( - () => mockAuthRemote.submitUserInfo(any()), + () => mockAuthRemote.submitUserInfo(any()), ).thenAnswer((_) async {}); when( - () => mockAuthRemote.login(authRequest: authRequest), + () => mockAuthRemote.login(authRequest: authRequest), ).thenAnswer((_) async => null); // Act - Step 1: Get user info (success) @@ -209,7 +209,7 @@ void main() { test('should track authentication state correctly', () async { // Arrange when( - () => mockAuthRemote.hasAuthenticated(), + () => mockAuthRemote.hasAuthenticated(), ).thenAnswer((_) async => true); // Act @@ -223,7 +223,7 @@ void main() { test('should handle authentication state changes', () async { // Arrange when( - () => mockAuthRemote.hasAuthenticated(), + () => mockAuthRemote.hasAuthenticated(), ).thenAnswer((_) async => false); // Act @@ -242,7 +242,7 @@ void main() { final expectedData = {'mobile': phone, 'device_name': null}; when( - () => mockAuthRemote.submitUserInfo(any()), + () => mockAuthRemote.submitUserInfo(any()), ).thenAnswer((_) async {}); // Act @@ -259,7 +259,7 @@ void main() { final expectedData = {'mobile': phone, 'device_name': deviceName}; when( - () => mockAuthRemote.submitUserInfo(any()), + () => mockAuthRemote.submitUserInfo(any()), ).thenAnswer((_) async {}); // Act diff --git a/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown.dart b/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown.dart index e418db9..3b5666b 100644 --- a/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown.dart +++ b/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown.dart @@ -1,70 +1,256 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:rasadyar_core/presentation/common/app_color.dart'; +import 'package:rasadyar_core/core.dart'; -import 'multi_select_dropdown_logic.dart'; - -class MultiSelectDropdown extends StatelessWidget { +/// A customizable searchable dropdown widget that supports both single and multi-select modes. +/// +/// The [SearchableDropdown] widget provides a text field with an overlay dropdown menu +/// that can be filtered by search. It supports two modes: +/// - **Single Select**: When [singleSelect] is `true`, only one item can be selected at a time. +/// Requires [singleLabelBuilder] to be provided. +/// - **Multi Select**: When [singleSelect] is `false`, multiple items can be selected. +/// Requires [multiLabelBuilder] to be provided and displays selected items as chips. +/// +/// The widget uses [SearchableDropdownLogic] for state management and overlay handling. +/// +/// Example usage (Single Select): +/// ```dart +/// SearchableDropdown( +/// items: ['Option 1', 'Option 2', 'Option 3'], +/// singleSelect: true, +/// singleLabelBuilder: (selected) => selected ?? 'Select an option', +/// itemBuilder: (item) => Text(item), +/// onChanged: (item) => print('Selected: $item'), +/// ) +/// ``` +/// +/// Example usage (Multi Select): +/// ```dart +/// SearchableDropdown( +/// items: ['Option 1', 'Option 2', 'Option 3'], +/// singleSelect: false, +/// multiLabelBuilder: (selected) => Chip(label: Text(selected.toString())), +/// itemBuilder: (item) => Text(item), +/// onChanged: (item) => print('Selected: $item'), +/// ) +/// ``` +/// +/// Example with custom search: +/// ```dart +/// SearchableDropdown( +/// items: users, +/// singleSelect: true, +/// singleLabelBuilder: (user) => user?.name ?? 'Select user', +/// itemBuilder: (user) => ListTile(title: Text(user.name)), +/// onSearch: (query) async { +/// return await searchUsers(query); +/// }, +/// ) +/// ``` +class SearchableDropdown extends StatelessWidget { + /// The list of items to display in the dropdown. final List items; - final T? selectedItem; - final T? initialValue; - final Widget Function(T item) itemBuilder; - final Widget Function(T? selected) labelBuilder; - final void Function(T selected)? onChanged; - final EdgeInsets? contentPadding; - final String Function(T item)? itemToString; - const MultiSelectDropdown({ + /// Pre-selected items. If provided, these items will be initially selected. + final List? selectedItem; + + /// Initial value for single select mode. Ignored if [selectedItem] is provided. + final T? initialValue; + + /// Hint text to display in the text field when no item is selected. + final String? hintText; + + /// Text style for the hint text. + final TextStyle? hintStyle; + + /// Whether the dropdown is in single select mode. + /// + /// - `true`: Only one item can be selected. Requires [singleLabelBuilder]. + /// - `false`: Multiple items can be selected. Requires [multiLabelBuilder]. + late final bool singleSelect; + + /// Builder function to create the widget for each item in the dropdown list. + /// + /// This is used to render items in the overlay dropdown menu. + final Widget Function(T item) itemBuilder; + + /// Builder function for single select mode to display the selected item as a string. + /// + /// Required when [singleSelect] is `true`. This function receives the selected item + /// and should return a string representation to display in the text field. + final String Function(T? selected)? singleLabelBuilder; + + /// Builder function for multi select mode to create widgets for selected items. + /// + /// Required when [singleSelect] is `false`. This function receives a selected item + /// and should return a widget (typically a Chip or similar) to display in the + /// horizontal list below the text field. + final Widget Function(T? selected)? multiLabelBuilder; + + /// Callback function called when an item is selected. + /// + /// Receives the selected item as a parameter. + final void Function(T selected)? onChanged; + + /// Padding for items in the dropdown list. + final EdgeInsets? contentPadding; + + /// Optional custom search function for filtering items. + /// + /// If provided, the text field becomes editable and this function is called + /// whenever the user types. The function receives the search query and should + /// return a filtered list of items. If `null`, the text field is read-only and + /// uses the default filtering behavior. + final Future?> Function(String query)? onSearch; + + final InputBorder _inputBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColor.darkGreyLight, width: 1), + ); + + /// Creates a [SearchableDropdown] widget. + /// + /// The [items] and [itemBuilder] parameters are required. + /// + /// When [singleSelect] is `true`, [singleLabelBuilder] must be provided and + /// [multiLabelBuilder] must be `null`. + /// + /// When [singleSelect] is `false`, [multiLabelBuilder] must be provided and + /// [singleLabelBuilder] must be `null`. + /// + /// Throws an [AssertionError] if the label builder requirements are not met. + SearchableDropdown({ super.key, required this.items, required this.itemBuilder, - required this.labelBuilder, + this.singleLabelBuilder, + this.multiLabelBuilder, this.initialValue, this.onChanged, this.selectedItem, this.contentPadding, - this.itemToString, - }); + this.hintText, + this.hintStyle, + this.onSearch, + this.singleSelect = false, + }) : assert( + (singleSelect && + singleLabelBuilder != null && + multiLabelBuilder == null) || + (!singleSelect && + multiLabelBuilder != null && + singleLabelBuilder == null), + 'When singleSelect is true, only singleLabelBuilder should be provided. ' + 'When singleSelect is false, only multiLabelBuilder should be provided.', + ); @override Widget build(BuildContext context) { - return GetBuilder>( - init: MultiSelectDropdownLogic( + return GetBuilder>( + init: SearchableDropdownLogic( items: items, selectedItem: selectedItem, initialValue: initialValue, - itemToString: itemToString, onChanged: onChanged, contentPadding: contentPadding, itemBuilder: itemBuilder, + labelBuilder: singleLabelBuilder, + onSearch: onSearch, + singleSelect: singleSelect, ), builder: (controller) { - return GestureDetector( - onTap: () { - controller.isOpen.value ? controller.removeOverlay() : controller.showOverlay(context); - }, - child: Container( - height: 40, - width: Get.width, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: items.isEmpty ? Colors.grey.shade200 : AppColor.bgLight, - border: Border.all(color: AppColor.darkGreyLight), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return Obx(() { + iLog(controller.selectedItem); + iLog(controller.selectedItem.length); + if (controller.selectedItem.isNotEmpty && !singleSelect) { + return Column( children: [ - Expanded(child: labelBuilder(controller.selectedItem.value)), - Icon( - controller.isOpen.value ? CupertinoIcons.chevron_up : CupertinoIcons.chevron_down, - size: 14, + CompositedTransformTarget( + link: controller.layerLink, + child: TextField( + controller: controller.searchController, + maxLines: 1, + minLines: 1, + readOnly: onSearch == null ? true : false, + onTapOutside: (_) => FocusScope.of(context).unfocus(), + onSubmitted: (_) => FocusScope.of(context).unfocus(), + decoration: InputDecoration( + filled: true, + fillColor: AppColor.bgLight, + border: _inputBorder, + focusedBorder: _inputBorder, + enabledBorder: _inputBorder, + hintText: hintText, + hintStyle: hintStyle, + suffixIcon: Icon( + controller.isOpen.value + ? CupertinoIcons.chevron_up + : CupertinoIcons.chevron_down, + size: 14, + ), + ), + onChanged: (query) => controller.performSearch(query), + onTap: () { + controller.isOpen.value + ? controller.removeOverlay() + : controller.showOverlay(context); + }, + ), + ), + const SizedBox(height: 4), + SizedBox( + height: 50, + child: ListView.separated( + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => SizedBox(width: 6), + itemBuilder: (context, index) => GestureDetector( + onTap: () { + controller.selectedItem.remove( + controller.selectedItem[index], + ); + }, + child: multiLabelBuilder!(controller.selectedItem[index]), + ), + itemCount: controller.selectedItem.length, + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), ), ], + ); + } + return CompositedTransformTarget( + link: controller.layerLink, + child: TextField( + controller: controller.searchController, + maxLines: 1, + minLines: 1, + readOnly: onSearch == null ? true : false, + onTapOutside: (_) => FocusScope.of(context).unfocus(), + onSubmitted: (_) => FocusScope.of(context).unfocus(), + decoration: InputDecoration( + filled: true, + fillColor: AppColor.bgLight, + border: _inputBorder, + focusedBorder: _inputBorder, + enabledBorder: _inputBorder, + hintText: hintText, + hintStyle: hintStyle, + suffixIcon: Icon( + controller.isOpen.value + ? CupertinoIcons.chevron_up + : CupertinoIcons.chevron_down, + size: 14, + ), + ), + onChanged: (query) => controller.performSearch(query), + onTap: () { + controller.isOpen.value + ? controller.removeOverlay() + : controller.showOverlay(context); + }, ), - ), - ); + ); + }); }, ); } diff --git a/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown_logic.dart b/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown_logic.dart index 6e25927..50b17d1 100644 --- a/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown_logic.dart +++ b/packages/core/lib/presentation/widget/overlay_dropdown_widget/multi_select_dropdown/multi_select_dropdown_logic.dart @@ -2,28 +2,103 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:rasadyar_core/presentation/common/app_color.dart'; -class MultiSelectDropdownLogic extends GetxController { +/// Controller class for managing the state and behavior of [SearchableDropdown]. +/// +/// This class extends [GetxController] and handles: +/// - Managing selected items (single or multiple) +/// - Filtering items based on search queries +/// - Showing and hiding the overlay dropdown menu +/// - Handling user interactions with the dropdown +/// +/// The controller maintains: +/// - [selectedItem]: Reactive list of currently selected items +/// - [filteredItems]: Reactive list of items filtered by search query +/// - [isOpen]: Reactive boolean indicating if the overlay is visible +/// - [searchController]: Text editing controller for the search field +/// - [layerLink]: Used for positioning the overlay relative to the text field +/// +/// Example usage: +/// ```dart +/// final controller = SearchableDropdownLogic( +/// items: ['Item 1', 'Item 2', 'Item 3'], +/// singleSelect: true, +/// itemBuilder: (item) => Text(item), +/// ); +/// ``` +class SearchableDropdownLogic extends GetxController { + /// The list of all available items. final List items; + + /// Initial value for single select mode. final T? initialValue; - final String Function(T item)? itemToString; + + /// Whether the dropdown is in single select mode. + final bool singleSelect; + + /// Hint text for the text field. + final String? hintText; + + /// Callback function called when an item is selected. final void Function(T selected)? onChanged; + /// Custom search function for filtering items. + /// + /// If provided, this function is called when the user types in the search field. + /// It should return a filtered list of items based on the query. + final Future?> Function(String query)? onSearch; + + /// The overlay entry for the dropdown menu. + OverlayEntry? _overlayEntry; + + /// Reactive boolean indicating whether the overlay is currently visible. RxBool isOpen = false.obs; - Rx selectedItem; + + /// Reactive list of currently selected items. + RxList selectedItem = RxList(); + + /// Text editing controller for the search field. late TextEditingController searchController; + + /// Reactive list of items filtered by the current search query. late RxList filteredItems; + + /// Padding for items in the dropdown list. late EdgeInsets? contentPadding; + + /// Builder function to create widgets for each item in the dropdown. late Widget Function(T item) itemBuilder; - MultiSelectDropdownLogic({ + /// Builder function for displaying the selected item label (single select mode). + final String Function(T? selected)? labelBuilder; + + /// Layer link used for positioning the overlay relative to the text field. + final LayerLink layerLink = LayerLink(); + + /// Creates a [SearchableDropdownLogic] controller. + /// + /// The [items] and [itemBuilder] parameters are required. + /// + /// If [selectedItem] is provided, it will be used as the initial selection. + /// Otherwise, if [initialValue] is provided (and [singleSelect] is `true`), + /// it will be used as the initial selection. + SearchableDropdownLogic({ required this.items, + this.singleSelect = false, this.initialValue, - this.itemToString, this.onChanged, - T? selectedItem, + this.hintText, + List? selectedItem, this.contentPadding, required this.itemBuilder, - }) : selectedItem = Rx(selectedItem ?? initialValue); + this.labelBuilder, + this.onSearch, + }) { + if (selectedItem != null) { + this.selectedItem.value = selectedItem; + } else { + this.selectedItem.value = initialValue != null ? [?initialValue] : []; + } + } @override void onInit() { @@ -32,114 +107,127 @@ class MultiSelectDropdownLogic extends GetxController { filteredItems = RxList(items); } + /// Shows the overlay dropdown menu below the text field. + /// + /// This method creates an [OverlayEntry] positioned relative to the text field + /// using [layerLink]. The overlay displays the filtered list of items and + /// handles item selection. + /// + /// When called, it: + /// - Clears the search controller + /// - Resets the filtered items to all items + /// - Creates and inserts the overlay entry + /// - Sets [isOpen] to `true` void showOverlay(BuildContext context) { final RenderBox renderBox = (context.findRenderObject() as RenderBox); final size = renderBox.size; - final offset = renderBox.localToGlobal(Offset.zero); - final screenHeight = MediaQuery.of(context).size.height; - - final bool openUp = offset.dy + size.height + 300 > screenHeight; searchController.clear(); filteredItems.value = items; - OverlayEntry overlayEntry = OverlayEntry( - builder: (_) => GestureDetector( - onTap: () { - removeOverlay(); - }, - child: Stack( - children: [ - Positioned( - left: offset.dx, - top: openUp ? offset.dy - 300 - 4 : offset.dy + size.height + 4, - width: size.width, - child: Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - child: Obx( - () => Container( - decoration: BoxDecoration( - color: AppColor.bgLight, - border: Border.all(color: AppColor.darkGreyLight), - borderRadius: BorderRadius.circular(8), - ), - constraints: BoxConstraints(maxHeight: 300), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextField( - controller: searchController, - decoration: const InputDecoration( - hintText: 'جستجو...', - isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - border: OutlineInputBorder(), - ), - onChanged: (query) { - filteredItems.value = items - .where( - (item) => - itemToString - ?.call(item) - .toLowerCase() - .contains(query.toLowerCase()) ?? - false, - ) - .toList(); - }, - ), + _overlayEntry = OverlayEntry( + builder: (context) { + return Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: removeOverlay, + child: Stack( + children: [ + CompositedTransformFollower( + link: layerLink, + showWhenUnlinked: false, + offset: Offset(0, size.height + 4), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Obx( + () => Container( + width: size.width, + constraints: const BoxConstraints(maxHeight: 300), + decoration: BoxDecoration( + color: AppColor.bgLight, + border: Border.all(color: AppColor.darkGreyLight), + borderRadius: BorderRadius.circular(8), ), - if (filteredItems.isEmpty) - const Padding( - padding: EdgeInsets.all(16.0), - child: Text("نتیجه‌ای یافت نشد."), - ), - if (filteredItems.isNotEmpty) - Flexible( - child: ListView.builder( - itemCount: filteredItems.length, - itemBuilder: (context, index) { - var item = filteredItems[index]; - return InkWell( - onTap: () { - onChanged?.call(item); - selectedItem.value = item; - removeOverlay(); - }, - child: Padding( - padding: - contentPadding ?? - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: itemBuilder(item), - ), - ); - }, - ), - ), - ], + child: (filteredItems.isEmpty) + ? const Center(child: Text("نتیجه‌ای یافت نشد.")) + : ListView.builder( + itemCount: filteredItems.length, + itemBuilder: (context, index) { + final item = filteredItems[index]; + return InkWell( + onTap: () { + if (!selectedItem.contains(item)) { + selectedItem.add(item); + } + + onChanged?.call(item); + removeOverlay(); + if (singleSelect) { + searchController.text = labelBuilder!( + item, + ); + } + }, + child: Padding( + padding: + contentPadding ?? + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: itemBuilder(item), + ), + ); + }, + ), + ), ), ), ), - ), + ], ), - ], - ), - ), + ), + ); + }, ); - Overlay.of(context).insert(overlayEntry); + Overlay.of(context).insert(_overlayEntry!); isOpen.value = true; } + /// Removes the overlay dropdown menu. + /// + /// This method removes the overlay entry from the overlay stack and sets + /// [isOpen] to `false`. void removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; isOpen.value = false; } + /// Performs a search operation to filter items. + /// + /// If [onSearch] is provided, it calls the custom search function with the + /// given [query] and updates [filteredItems] with the result. + /// + /// After updating the filtered items, it marks the overlay entry for rebuild + /// to reflect the new filtered results. + void performSearch(String query) async { + if (onSearch != null) { + final result = await onSearch!(query); + filteredItems.value = result ?? []; + } + + if (_overlayEntry != null) { + _overlayEntry!.markNeedsBuild(); + } + } + @override void onClose() { searchController.dispose(); + removeOverlay(); super.onClose(); } } diff --git a/packages/core/lib/utils/extension/string_utils.dart b/packages/core/lib/utils/extension/string_utils.dart index 15c289a..2663144 100644 --- a/packages/core/lib/utils/extension/string_utils.dart +++ b/packages/core/lib/utils/extension/string_utils.dart @@ -61,4 +61,9 @@ extension XString on String? { } int get versionNumber => int.parse(this?.replaceAll(".", '') ?? '0'); + + bool get isDifferentDigits { + final regex = RegExp(r'[۰-۹٠-٩]'); + return regex.hasMatch(this ?? ''); + } } diff --git a/packages/core/lib/utils/map_utils.dart b/packages/core/lib/utils/map_utils.dart index 3278207..0f65d79 100644 --- a/packages/core/lib/utils/map_utils.dart +++ b/packages/core/lib/utils/map_utils.dart @@ -114,3 +114,16 @@ Map? buildRawQueryParams({ return params.keys.isEmpty ? null : params; } + +const Map digitMap = { + '۰': '0', + '۱': '1', + '۲': '2', + '۳': '3', + '۴': '4', + '۵': '5', + '۶': '6', + '۷': '7', + '۸': '8', + '۹': '9', +}; diff --git a/packages/core/lib/utils/first_digit_decimal_formatter.dart b/packages/core/lib/utils/text_input_formatter/first_digit_decimal_formatter.dart similarity index 100% rename from packages/core/lib/utils/first_digit_decimal_formatter.dart rename to packages/core/lib/utils/text_input_formatter/first_digit_decimal_formatter.dart diff --git a/packages/core/lib/utils/text_input_formatter/persian_formatter.dart b/packages/core/lib/utils/text_input_formatter/persian_formatter.dart new file mode 100644 index 0000000..14eca87 --- /dev/null +++ b/packages/core/lib/utils/text_input_formatter/persian_formatter.dart @@ -0,0 +1,23 @@ +import 'package:flutter/services.dart'; + +import '../map_utils.dart'; + +class PersianFormatter extends TextInputFormatter { + String _convert(String input) { + final buffer = StringBuffer(); + for (var char in input.split('')) { + buffer.write(digitMap[char] ?? char); + } + return buffer.toString(); + } + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final fixed = _convert(newValue.text); + + return newValue.copyWith( + text: fixed, + selection: TextSelection.collapsed(offset: fixed.length), + ); + } +} diff --git a/packages/core/lib/utils/utils.dart b/packages/core/lib/utils/utils.dart index 4ceb568..11d538e 100644 --- a/packages/core/lib/utils/utils.dart +++ b/packages/core/lib/utils/utils.dart @@ -14,4 +14,5 @@ export 'number_utils.dart'; export 'parser.dart'; export 'route_utils.dart'; export 'separator_input_formatter.dart'; -export 'first_digit_decimal_formatter.dart'; +export 'text_input_formatter/first_digit_decimal_formatter.dart'; +export 'text_input_formatter/persian_formatter.dart';