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,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';