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:
@@ -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
|
||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user