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.buildMode=release
flutter.buildMode=debug
flutter.versionName=1.3.32
flutter.versionCode=29

View File

@@ -11,8 +11,6 @@ abstract class AuthRemoteDataSource {
Future<UserInfoModel?> getUserInfo(String phoneNumber);
Future<void> submitUserInfo(Map<String, dynamic> 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<void> stewardAppLogin({required String token, Map<String, dynamic>? queryParameters});
}

View File

@@ -48,15 +48,6 @@ class AuthRemoteDataSourceImp extends AuthRemoteDataSource {
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
Future<void> 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'},
);
}

View File

@@ -10,8 +10,6 @@ abstract class AuthRepository {
Future<UserInfoModel?> getUserInfo(String phoneNumber);
Future<void> 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<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 =>
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
Future<void> stewardAppLogin({
required String token,

View File

@@ -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,

View File

@@ -133,6 +133,7 @@ class AuthPage extends GetView<AuthLogic> {
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<AuthLogic> {
autofillHints: [AutofillHints.password],
variant: RTextFieldVariant.password,
initText: passwordController.value.text,
inputFormatters: [PersianFormatter()],
onChanged: (value) {
passwordController.refresh();
},

View File

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

View File

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

View File

@@ -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

View File

@@ -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<T> 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<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 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,
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<MultiSelectDropdownLogic<T>>(
init: MultiSelectDropdownLogic<T>(
return GetBuilder<SearchableDropdownLogic<T>>(
init: SearchableDropdownLogic<T>(
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);
},
),
),
);
);
});
},
);
}

View File

@@ -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<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;
/// 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<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;
Rx<T?> selectedItem;
/// Reactive list of currently selected items.
RxList<T> selectedItem = RxList<T>();
/// Text editing controller for the search field.
late TextEditingController searchController;
/// Reactive list of items filtered by the current search query.
late RxList<T> 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<T>? 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<T> extends GetxController {
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) {
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();
}
}

View File

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

View File

@@ -114,3 +114,16 @@ Map<String, dynamic>? buildRawQueryParams({
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 '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';