refactor: remove the unused submitUserInfo method and enhance the searchable dropdown functionality

- Removed `submitUserInfo` from auth services and integrations.
- Refined dropdown with new multi-select and searchable options.
- Added `PersianFormatter` for better input handling.
- Updated `local.properties` to set flutter build mode to debug.
This commit is contained in:
2025-11-15 16:00:47 +03:30
parent 63d18cedca
commit 716a7ed259
17 changed files with 504 additions and 186 deletions

View File

@@ -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.sdk=C:\\src\\flutter
flutter.buildMode=release flutter.buildMode=debug
flutter.versionName=1.3.32 flutter.versionName=1.3.32
flutter.versionCode=29 flutter.versionCode=29

View File

@@ -11,8 +11,6 @@ abstract class AuthRemoteDataSource {
Future<UserInfoModel?> getUserInfo(String phoneNumber); Future<UserInfoModel?> getUserInfo(String phoneNumber);
Future<void> submitUserInfo(Map<String, dynamic> userInfo); /// Calls `/steward-app-login/` endpoint with required token and `server` as query param, plus optional extra query parameters.
/// Calls `/steward-app-login/` endpoint with optional query parameters and required token header.
Future<void> stewardAppLogin({required String token, Map<String, dynamic>? queryParameters}); Future<void> stewardAppLogin({required String token, Map<String, dynamic>? queryParameters});
} }

View File

@@ -48,15 +48,6 @@ class AuthRemoteDataSourceImp extends AuthRemoteDataSource {
return res.data; return res.data;
} }
@override
Future<void> submitUserInfo(Map<String, dynamic> userInfo) async {
await _httpClient.post(
'/steward-app-login/',
data: userInfo,
headers: {'Content-Type': 'application/json'},
);
}
@override @override
Future<void> stewardAppLogin({ Future<void> stewardAppLogin({
required String token, required String token,
@@ -64,7 +55,7 @@ class AuthRemoteDataSourceImp extends AuthRemoteDataSource {
}) async { }) async {
await _httpClient.post( await _httpClient.post(
'/steward-app-login/', '/steward-app-login/',
queryParameters: queryParameters, data: queryParameters,
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'}, headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'},
); );
} }

View File

@@ -10,8 +10,6 @@ abstract class AuthRepository {
Future<UserInfoModel?> getUserInfo(String phoneNumber); Future<UserInfoModel?> getUserInfo(String phoneNumber);
Future<void> submitUserInfo({required String phone, String? deviceName}); /// Calls `/steward-app-login/` with Bearer token and required `server` query param.
/// Calls `/steward-app-login/` with Bearer token and optional query parameters.
Future<void> stewardAppLogin({required String token, Map<String, dynamic>? queryParameters}); Future<void> stewardAppLogin({required String token, Map<String, dynamic>? queryParameters});
} }

View File

@@ -23,12 +23,6 @@ class AuthRepositoryImpl implements AuthRepository {
Future<UserInfoModel?> getUserInfo(String phoneNumber) async => Future<UserInfoModel?> getUserInfo(String phoneNumber) async =>
await authRemote.getUserInfo(phoneNumber); await authRemote.getUserInfo(phoneNumber);
@override
Future<void> submitUserInfo({required String phone, String? deviceName}) async {
var tmp = {'mobile': phone, 'device_name': deviceName};
await authRemote.submitUserInfo(tmp);
}
@override @override
Future<void> stewardAppLogin({ Future<void> stewardAppLogin({
required String token, required String token,

View File

@@ -131,12 +131,7 @@ class AuthLogic extends GetxController with GetTickerProviderStateMixin {
); );
} }
authRepository.submitUserInfo( authTmp.stewardAppLogin(
phone: usernameController.value.text,
deviceName: deviceName.value,
);
authRepository.stewardAppLogin(
token: result?.accessToken ?? '', token: result?.accessToken ?? '',
queryParameters: { queryParameters: {
"mobile": usernameController.value.text, "mobile": usernameController.value.text,

View File

@@ -133,6 +133,7 @@ class AuthPage extends GetView<AuthLogic> {
maxLines: 1, maxLines: 1,
controller: controller.usernameController.value, controller: controller.usernameController.value,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [PersianFormatter()],
initText: controller.usernameController.value.text, initText: controller.usernameController.value.text,
autofillHints: [AutofillHints.username], autofillHints: [AutofillHints.username],
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
@@ -187,6 +188,7 @@ class AuthPage extends GetView<AuthLogic> {
autofillHints: [AutofillHints.password], autofillHints: [AutofillHints.password],
variant: RTextFieldVariant.password, variant: RTextFieldVariant.password,
initText: passwordController.value.text, initText: passwordController.value.text,
inputFormatters: [PersianFormatter()],
onChanged: (value) { onChanged: (value) {
passwordController.refresh(); passwordController.refresh();
}, },

View File

@@ -436,7 +436,6 @@ class HomePage extends GetView<HomeLogic> {
Expanded( Expanded(
child: _informationLabelCard( child: _informationLabelCard(
title: 'مانده دولتی', title: 'مانده دولتی',
titleColor: AppColor.blueNormal,
isLoading: data.value == null, isLoading: data.value == null,
description: data.value?.totalGovernmentalRemainWeight?.separatedByCommaFa ?? '0', description: data.value?.totalGovernmentalRemainWeight?.separatedByCommaFa ?? '0',
iconPath: Assets.vec.cubeCardGovermentSvg.path, iconPath: Assets.vec.cubeCardGovermentSvg.path,

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/data/models/response/roles_products/roles_products.dart';
import 'package:rasadyar_chicken/presentation/pages/steward/sales_in_province/logic.dart'; import 'package:rasadyar_chicken/presentation/pages/steward/sales_in_province/logic.dart';
import 'package:rasadyar_core/core.dart'; import 'package:rasadyar_core/core.dart';
@@ -288,23 +287,49 @@ Widget addOrEditBottomSheet(SalesInProvinceLogic controller, {bool isEditMode =
Widget guildsDropDown(SalesInProvinceLogic controller) { Widget guildsDropDown(SalesInProvinceLogic controller) {
return Obx(() { return Obx(() {
final item = controller.selectedGuildModel.value; final item = controller.selectedGuildModel.value;
return OverlayDropdownWidget<GuildModel>(
key: ValueKey(item?.user?.fullname ?? ''), return SearchableDropdown(
items: controller.guildsModel,
onChanged: (value) { onChanged: (value) {
controller.selectedGuildModel.value = value; controller.selectedGuildModel.value = value;
}, },
selectedItem: item, selectedItem: [?item],
singleSelect: false,
items: controller.guildsModel,
hintText: 'انتخاب مباشر/صنف',
itemBuilder: (item) => Text( itemBuilder: (item) => Text(
item.user != null item.user != null
? '${item.steward == true ? 'مباشر' : 'صنف'} ${item.user!.fullname} (${item.user!.mobile})' ? '${item.steward == true ? 'مباشر' : 'صنف'} ${item.user!.fullname} (${item.user!.mobile})'
: 'بدون نام', : 'بدون نام',
), ),
labelBuilder: (item) => Text( multiLabelBuilder: (item) => Container(
item?.user != null decoration: BoxDecoration(
? '${item?.steward == true ? 'مباشر' : 'صنف'} ${item?.user!.fullname} (${item?.user!.mobile})' 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(),
);
});
},
); );
}); });
} }

View File

@@ -48,15 +48,15 @@ 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.submitUserInfo(any()),
).thenAnswer((_) async {}); ).thenAnswer((_) async {});
when( when(
() => mockAuthRemote.login(authRequest: authRequest), () => mockAuthRemote.login(authRequest: authRequest),
).thenAnswer((_) async => expectedUserProfile); ).thenAnswer((_) async => expectedUserProfile);
// Act - Step 1: Get user info // Act - Step 1: Get user info
@@ -77,7 +77,7 @@ 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.submitUserInfo(any())).called(1);
verify(() => mockAuthRemote.login(authRequest: authRequest)).called(1); verify(() => mockAuthRemote.login(authRequest: authRequest)).called(1);
}); });
@@ -100,11 +100,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 +129,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
@@ -158,15 +158,15 @@ 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.submitUserInfo(any()),
).thenAnswer((_) async {}); ).thenAnswer((_) async {});
when( when(
() => mockAuthRemote.login(authRequest: authRequest), () => mockAuthRemote.login(authRequest: authRequest),
).thenAnswer((_) async => null); ).thenAnswer((_) async => null);
// Act - Step 1: Get user info (success) // Act - Step 1: Get user info (success)
@@ -209,7 +209,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 +223,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
@@ -242,7 +242,7 @@ void main() {
final expectedData = {'mobile': phone, 'device_name': null}; final expectedData = {'mobile': phone, 'device_name': null};
when( when(
() => mockAuthRemote.submitUserInfo(any()), () => mockAuthRemote.submitUserInfo(any()),
).thenAnswer((_) async {}); ).thenAnswer((_) async {});
// Act // Act
@@ -259,7 +259,7 @@ void main() {
final expectedData = {'mobile': phone, 'device_name': deviceName}; final expectedData = {'mobile': phone, 'device_name': deviceName};
when( when(
() => mockAuthRemote.submitUserInfo(any()), () => mockAuthRemote.submitUserInfo(any()),
).thenAnswer((_) async {}); ).thenAnswer((_) async {});
// Act // Act

View File

@@ -1,70 +1,256 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:rasadyar_core/core.dart';
import 'package:rasadyar_core/presentation/common/app_color.dart';
import 'multi_select_dropdown_logic.dart'; /// A customizable searchable dropdown widget that supports both single and multi-select modes.
///
class MultiSelectDropdown<T> extends StatelessWidget { /// 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<String>(
/// 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<String>(
/// 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<User>(
/// 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<T> extends StatelessWidget {
/// The list of items to display in the dropdown.
final List<T> items; final List<T> 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<T>? 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<List<T>?> 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, super.key,
required this.items, required this.items,
required this.itemBuilder, required this.itemBuilder,
required this.labelBuilder, this.singleLabelBuilder,
this.multiLabelBuilder,
this.initialValue, this.initialValue,
this.onChanged, this.onChanged,
this.selectedItem, this.selectedItem,
this.contentPadding, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GetBuilder<MultiSelectDropdownLogic<T>>( return GetBuilder<SearchableDropdownLogic<T>>(
init: MultiSelectDropdownLogic<T>( init: SearchableDropdownLogic<T>(
items: items, items: items,
selectedItem: selectedItem, selectedItem: selectedItem,
initialValue: initialValue, initialValue: initialValue,
itemToString: itemToString,
onChanged: onChanged, onChanged: onChanged,
contentPadding: contentPadding, contentPadding: contentPadding,
itemBuilder: itemBuilder, itemBuilder: itemBuilder,
labelBuilder: singleLabelBuilder,
onSearch: onSearch,
singleSelect: singleSelect,
), ),
builder: (controller) { builder: (controller) {
return GestureDetector( return Obx(() {
onTap: () { iLog(controller.selectedItem);
controller.isOpen.value ? controller.removeOverlay() : controller.showOverlay(context); iLog(controller.selectedItem.length);
}, if (controller.selectedItem.isNotEmpty && !singleSelect) {
child: Container( return Column(
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,
children: [ children: [
Expanded(child: labelBuilder(controller.selectedItem.value)), CompositedTransformTarget(
Icon( link: controller.layerLink,
controller.isOpen.value ? CupertinoIcons.chevron_up : CupertinoIcons.chevron_down, child: TextField(
size: 14, 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);
},
), ),
), );
); });
}, },
); );
} }

View File

@@ -2,28 +2,103 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:rasadyar_core/presentation/common/app_color.dart'; import 'package:rasadyar_core/presentation/common/app_color.dart';
class MultiSelectDropdownLogic<T> 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<String>(
/// items: ['Item 1', 'Item 2', 'Item 3'],
/// singleSelect: true,
/// itemBuilder: (item) => Text(item),
/// );
/// ```
class SearchableDropdownLogic<T> extends GetxController {
/// The list of all available items.
final List<T> items; final List<T> items;
/// Initial value for single select mode.
final T? initialValue; 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; 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<List<T>?> 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; RxBool isOpen = false.obs;
Rx<T?> selectedItem;
/// Reactive list of currently selected items.
RxList<T> selectedItem = RxList<T>();
/// Text editing controller for the search field.
late TextEditingController searchController; late TextEditingController searchController;
/// Reactive list of items filtered by the current search query.
late RxList<T> filteredItems; late RxList<T> filteredItems;
/// Padding for items in the dropdown list.
late EdgeInsets? contentPadding; late EdgeInsets? contentPadding;
/// Builder function to create widgets for each item in the dropdown.
late Widget Function(T item) itemBuilder; 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, required this.items,
this.singleSelect = false,
this.initialValue, this.initialValue,
this.itemToString,
this.onChanged, this.onChanged,
T? selectedItem, this.hintText,
List<T>? selectedItem,
this.contentPadding, this.contentPadding,
required this.itemBuilder, 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 @override
void onInit() { void onInit() {
@@ -32,114 +107,127 @@ class MultiSelectDropdownLogic<T> extends GetxController {
filteredItems = RxList<T>(items); filteredItems = RxList<T>(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) { void showOverlay(BuildContext context) {
final RenderBox renderBox = (context.findRenderObject() as RenderBox); final RenderBox renderBox = (context.findRenderObject() as RenderBox);
final size = renderBox.size; 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(); searchController.clear();
filteredItems.value = items; filteredItems.value = items;
OverlayEntry overlayEntry = OverlayEntry( _overlayEntry = OverlayEntry(
builder: (_) => GestureDetector( builder: (context) {
onTap: () { return Positioned.fill(
removeOverlay(); child: GestureDetector(
}, behavior: HitTestBehavior.translucent,
child: Stack( onTap: removeOverlay,
children: [ child: Stack(
Positioned( children: [
left: offset.dx, CompositedTransformFollower(
top: openUp ? offset.dy - 300 - 4 : offset.dy + size.height + 4, link: layerLink,
width: size.width, showWhenUnlinked: false,
child: Material( offset: Offset(0, size.height + 4),
elevation: 4, child: Material(
borderRadius: BorderRadius.circular(8), elevation: 4,
child: Obx( borderRadius: BorderRadius.circular(8),
() => Container( child: Obx(
decoration: BoxDecoration( () => Container(
color: AppColor.bgLight, width: size.width,
border: Border.all(color: AppColor.darkGreyLight), constraints: const BoxConstraints(maxHeight: 300),
borderRadius: BorderRadius.circular(8), decoration: BoxDecoration(
), color: AppColor.bgLight,
constraints: BoxConstraints(maxHeight: 300), border: Border.all(color: AppColor.darkGreyLight),
child: Column( borderRadius: BorderRadius.circular(8),
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();
},
),
), ),
if (filteredItems.isEmpty) child: (filteredItems.isEmpty)
const Padding( ? const Center(child: Text("نتیجه‌ای یافت نشد."))
padding: EdgeInsets.all(16.0), : ListView.builder(
child: Text("نتیجه‌ای یافت نشد."), itemCount: filteredItems.length,
), itemBuilder: (context, index) {
if (filteredItems.isNotEmpty) final item = filteredItems[index];
Flexible( return InkWell(
child: ListView.builder( onTap: () {
itemCount: filteredItems.length, if (!selectedItem.contains(item)) {
itemBuilder: (context, index) { selectedItem.add(item);
var item = filteredItems[index]; }
return InkWell(
onTap: () { onChanged?.call(item);
onChanged?.call(item); removeOverlay();
selectedItem.value = item; if (singleSelect) {
removeOverlay(); searchController.text = labelBuilder!(
}, item,
child: Padding( );
padding: }
contentPadding ?? },
const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Padding(
child: itemBuilder(item), 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; 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() { void removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
isOpen.value = false; 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 @override
void onClose() { void onClose() {
searchController.dispose(); searchController.dispose();
removeOverlay();
super.onClose(); super.onClose();
} }
} }

View File

@@ -61,4 +61,9 @@ extension XString on String? {
} }
int get versionNumber => int.parse(this?.replaceAll(".", '') ?? '0'); int get versionNumber => int.parse(this?.replaceAll(".", '') ?? '0');
bool get isDifferentDigits {
final regex = RegExp(r'[۰-۹٠-٩]');
return regex.hasMatch(this ?? '');
}
} }

View File

@@ -114,3 +114,16 @@ Map<String, dynamic>? buildRawQueryParams({
return params.keys.isEmpty ? null : params; return params.keys.isEmpty ? null : params;
} }
const Map<String, String> digitMap = {
'۰': '0',
'۱': '1',
'۲': '2',
'۳': '3',
'۴': '4',
'۵': '5',
'۶': '6',
'۷': '7',
'۸': '8',
'۹': '9',
};

View File

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

View File

@@ -14,4 +14,5 @@ export 'number_utils.dart';
export 'parser.dart'; export 'parser.dart';
export 'route_utils.dart'; export 'route_utils.dart';
export 'separator_input_formatter.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';