diff --git a/lib/apis/api.dart b/lib/apis/api.dart index 8cfc206..7aac115 100644 --- a/lib/apis/api.dart +++ b/lib/apis/api.dart @@ -33,19 +33,24 @@ class Api { ); } -// 获取我的个人信息 + /// 获取我的个人信息 static Future getMyProfile() { return DioService.dio.get( - Urls.userInfo, + Urls.myProfile, ); } -// 获取个人信息 + /// 获取个人信息 static Future getUserInfo(int userid) { - return DioService.dio.get('${Urls.userInfo}/$userid'); + return DioService.dio.get('${Urls.sysUser}/$userid'); } -// 分页获取项目列表 + /// 更新个人信息 + static Future updateUserInfo(int userid, Map params) { + return DioService.dio.put('${Urls.sysUser}/$userid', data: params); + } + + /// 分页获取项目列表 static Future> getProjects(Map params) { return DioService.dio.get(Urls.projects, queryParameters: {'page': 1, 'pageSize': 10, ...params}); diff --git a/lib/app_theme.dart b/lib/app_theme.dart index bdc2388..80f9ec8 100644 --- a/lib/app_theme.dart +++ b/lib/app_theme.dart @@ -121,7 +121,7 @@ final theme = ThemeData( color: AppTheme.grey, fontSize: ScreenAdaper.height(25), ), - fillColor: AppTheme.inputFillColor, + fillColor: Colors.transparent, filled: true, labelStyle: TextStyle( fontSize: ScreenAdaper.height(25), diff --git a/lib/constants/enum.dart b/lib/constants/enum.dart index 9068e91..295559b 100644 --- a/lib/constants/enum.dart +++ b/lib/constants/enum.dart @@ -17,3 +17,6 @@ class StorageBussinessModuleEnum { static const Product = 'product'; static const Project = 'project'; } + +/** 超级管理员角色 id */ +final ROOT_ROLE_ID = 1; diff --git a/lib/constants/global_url.dart b/lib/constants/global_url.dart index 7226408..247a839 100644 --- a/lib/constants/global_url.dart +++ b/lib/constants/global_url.dart @@ -15,7 +15,6 @@ class Urls { static String uploadAttachemnt = 'tools/upload'; static String systemParamConfig = 'system/param-config'; static String accountMenus = 'account/menus'; - static String userInfo = 'system/users'; static String depts = 'system/depts'; static String roles = 'system/roles'; } diff --git a/lib/models/user_info.model.dart b/lib/models/user_info.model.dart index d37b33c..d5a8aa3 100644 --- a/lib/models/user_info.model.dart +++ b/lib/models/user_info.model.dart @@ -22,16 +22,16 @@ class UserInfoModel { final int? id; final DateTime? createdAt; final DateTime? updatedAt; - final String? username; - final String? psalt; - final String? nickname; - final String? avatar; - final String? qq; - final String? email; - final String? phone; - final String? remark; - final int? status; - final DeptModel? dept; + String? username; + String? psalt; + String? nickname; + String? avatar; + String? qq; + String? email; + String? phone; + String? remark; + int? status; + DeptModel? dept; List roles; factory UserInfoModel.fromJson(Map json) { diff --git a/lib/screens/hr_manage/components/dept_picker.dart b/lib/screens/hr_manage/components/dept_picker.dart index 82183d6..07ddafa 100644 --- a/lib/screens/hr_manage/components/dept_picker.dart +++ b/lib/screens/hr_manage/components/dept_picker.dart @@ -8,7 +8,7 @@ import 'package:sk_base_mobile/util/screen_adaper_util.dart'; import 'package:sk_base_mobile/widgets/core/sk_cascade_picker.dart'; class DeptPicker extends StatelessWidget { - final Function(String)? onSelected; + final Function(CascadeItem)? onSelected; DeptPicker({super.key, this.onSelected}); @override Widget build(BuildContext context) { @@ -29,14 +29,10 @@ class DeptPicker extends StatelessWidget { }, onConfirm: (List> value) { if (onSelected != null && value.isNotEmpty) { - onSelected!(value.last.label); + onSelected!(value.last); } }, maxPageNum: 10, - tabTitleStyle: - TextStyle(fontSize: ScreenAdaper.height(26), color: Colors.black), - itemTitleStyle: - TextStyle(fontSize: ScreenAdaper.height(26), color: Colors.black), selectedIcon: const Icon(Icons.check, color: AppTheme.primaryColorLight), ); } diff --git a/lib/screens/hr_manage/components/edit_userinfo.dart b/lib/screens/hr_manage/components/edit_userinfo.dart index 016f372..9fbfa21 100644 --- a/lib/screens/hr_manage/components/edit_userinfo.dart +++ b/lib/screens/hr_manage/components/edit_userinfo.dart @@ -2,21 +2,26 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:sk_base_mobile/apis/api.dart'; import 'package:sk_base_mobile/app_theme.dart'; import 'package:sk_base_mobile/constants/bg_color.dart'; +import 'package:sk_base_mobile/constants/enum.dart'; import 'package:sk_base_mobile/models/role.model.dart'; import 'package:sk_base_mobile/models/user_info.model.dart'; import 'package:sk_base_mobile/screens/hr_manage/components/dept_picker.dart'; +import 'package:sk_base_mobile/util/loading_util.dart'; import 'package:sk_base_mobile/util/logger_util.dart'; +import 'package:sk_base_mobile/util/media_util.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; import 'package:sk_base_mobile/util/snack_bar.util.dart'; +import 'package:sk_base_mobile/widgets/common/multi-picker/models/value_item.dart'; import 'package:sk_base_mobile/widgets/core/sk_cascade_picker.dart'; import 'package:sk_base_mobile/widgets/core/sk_dialog_header.dart'; -import 'package:sk_base_mobile/widgets/core/sk_multi_picker.dart'; -import 'package:sk_base_mobile/widgets/core/sk_multi_picker_dialog.dart'; -import 'package:sk_base_mobile/widgets/core/sk_text_input.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_multi_picker.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_text_input.dart'; import 'package:sk_base_mobile/widgets/gradient_button.dart'; +import 'package:sk_base_mobile/widgets/loading_indicator.dart'; /// 编辑用户信息 class EditUserInfo extends StatelessWidget { @@ -27,123 +32,129 @@ class EditUserInfo extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - FocusScope.of(context).requestFocus(FocusNode()); - }, - child: buildBody(), + return Scaffold( + // appBar: PreferredSize( + // preferredSize: Size.fromHeight(40), + // child: AppBar(elevation: 0, title: const Text('首页')), + // ), + backgroundColor: AppTheme.nearlyWhite, + body: buildBody(), ); + // return buildBody() } Widget buildBody() { return Column( children: [ - const SkDialogHeader(title: '编辑员工信息'), + const SkDialogHeader( + title: '编辑员工信息', + ), Expanded( - child: SingleChildScrollView( - child: Container( - padding: EdgeInsets.symmetric( - horizontal: ScreenAdaper.height(15), - vertical: ScreenAdaper.height(15)), - child: Column(children: [ - buildAvatar(), - SizedBox( - height: ScreenAdaper.height(defaultPadding), - ), - - /// 姓名 - SkTextInput( - isDense: true, - textController: controller.nameEditController, - customLabel: true, - labelText: '姓名', - ), - SizedBox( - height: ScreenAdaper.height(defaultPadding), - ), - - /// 部门 - SkTextInput( - isDense: true, - customLabel: true, - keyboardType: TextInputType.none, - textController: controller.deptEditController, - labelText: '所属部门', - onTap: (_) async { - Get.bottomSheet(DeptPicker(onSelected: (String label) { - controller.deptEditController.text = label; - })); + child: GestureDetector( + onTap: () { + FocusScope.of(Get.context!).requestFocus(FocusNode()); }, - ), - SizedBox( - height: ScreenAdaper.height(defaultPadding), - ), + child: Obx(() => controller.loading.value + ? const LoadingIndicator( + common: true, + ) + : SingleChildScrollView( + child: Container( + padding: EdgeInsets.symmetric( + horizontal: ScreenAdaper.height(15), + vertical: ScreenAdaper.height(15)), + child: Column(children: [ + buildAvatar(), + SizedBox( + height: ScreenAdaper.height(defaultPadding), + ), - /// 角色 - SkMultiPickerDropdown( - isDense: true, - customLabel: true, - keyboardType: TextInputType.none, - textController: controller.roleEditController, - labelText: '角色', - hint: '选择角色', - onTap: (_) async { - Get.bottomSheet(SkMutiPickerDialog( - onSelected: (List selectedData) { - controller.roleEditController.text; - }, getData: () async { - try { - final res = - await Api.getRoles({'page': 1, 'pageSize': 30}); - if (res.data != null) { - List> result = - res.data!.items.map>((e) { - RoleModel data = RoleModel.fromJson(e); - return PickerItem( - label: data.name, - value: data.id!, - subLabel: data.remark, - checked: false); - }).toList(); - return result; - } - return []; - } catch (e) { - LoggerUtil().error(e); - return []; - } - })).then( - (value) => Get.delete()); - }), - SizedBox( - height: ScreenAdaper.height(defaultPadding), - ), - SkTextInput( - isDense: true, - textController: controller.nickNameEditController, - customLabel: true, - labelText: '登录用户名', - ), - SizedBox( - height: ScreenAdaper.height(defaultPadding), - ), - SkTextInput( - isDense: true, - customLabel: true, - textController: TextEditingController(), - labelText: '手机号', - ), - ]), - ), - )), - const GradientButton( + /// 姓名 + SkTextInput( + isDense: true, + isRequired: true, + textController: controller.nicknameEditController, + customLabel: true, + labelText: '姓名', + ), + SizedBox( + height: ScreenAdaper.height(defaultPadding), + ), + + /// 部门 + SkTextInput( + isDense: true, + customLabel: true, + isRequired: true, + keyboardType: TextInputType.none, + textController: controller.deptEditController, + labelText: '所属部门', + onTap: (_) async { + Get.bottomSheet( + DeptPicker(onSelected: (CascadeItem item) { + controller.deptEditController.text = + item.label; + controller.deptId = item.value; + })); + }, + ), + SizedBox( + height: ScreenAdaper.height(defaultPadding), + ), + + /// 角色 + SkMultiPickerDropdown( + isDense: true, + customLabel: true, + labelText: '角色', + isRequired: true, + hint: '选择角色', + options: controller.roles, + onOptionSelected: + (List> selectedData) => { + controller.roleSelection.assignAll(selectedData) + }, + selectedOptions: controller.roleSelection, + ), + SizedBox( + height: ScreenAdaper.height(defaultPadding), + ), + SkTextInput( + isDense: true, + isRequired: true, + textController: controller.usernameEditController, + customLabel: true, + labelText: '登录用户名', + ), + SizedBox( + height: ScreenAdaper.height(defaultPadding), + ), + SkTextInput( + isDense: true, + customLabel: true, + textController: controller.phoneEditController, + labelText: '手机号', + ), + SizedBox( + height: ScreenAdaper.height(defaultPadding), + ), + SkTextInput( + isDense: true, + customLabel: true, + textController: controller.emailEditController, + labelText: '邮箱', + ), + ]), + ), + )))), + SizedBox( + height: ScreenAdaper.height(10), + ), + GradientButton( borderRadius: BorderRadius.zero, - buttonText: '提交', + onPressed: controller.submit, + buttonText: '保存', ) - // Container( - // decoration: BoxDecoration(color: AppTheme.primaryColor), - // height: 100, - // ) ], ); } @@ -154,12 +165,15 @@ class EditUserInfo extends StatelessWidget { SizedBox( height: ScreenAdaper.height(10), ), - buildImageUploader(), + Obx(() => controller.filePath.isEmpty && + controller.userInfo.value.avatar == null + ? buildImageUploader() + : builderImagePreview()), ], ); } - Widget builderImagePreview(String path, String type) { + Widget builderImagePreview() { return Stack( children: [ Container( @@ -173,14 +187,16 @@ class EditUserInfo extends StatelessWidget { ), child: Center( child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - image: DecorationImage( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + image: DecorationImage( fit: BoxFit.cover, - image: FileImage(File(path)), - ), - ), - ), + image: controller.filePath.value.isNotEmpty + ? FileImage(File(controller.filePath.value)) + : NetworkImage(MediaUtil.getMediaUrl( + controller.userInfo.value!.avatar!)) + as ImageProvider), + )), ), ), Positioned( @@ -188,11 +204,8 @@ class EditUserInfo extends StatelessWidget { right: ScreenAdaper.width(5), child: GestureDetector( onTap: () { - if (type == 'agent') { - // controller.uploadAgentImgFilesPath.remove(path); - } else { - // controller.uploadProductImgFilesPath.remove(path); - } + controller.filePath.value = ''; + controller.userInfo.value.avatar = null; }, child: Icon( Icons.close, @@ -220,7 +233,7 @@ class EditUserInfo extends StatelessWidget { Widget buildImageUploader() { return GestureDetector( onTap: () { - // controller.photoPicker(type); + controller.photoPicker(); }, child: Container( margin: EdgeInsets.symmetric(horizontal: ScreenAdaper.width(5)), @@ -241,29 +254,145 @@ class EditUserInfo extends StatelessWidget { } class EditUserInfoController extends GetxController { - int userId; + final int userId; EditUserInfoController(this.userId); - final nameEditController = TextEditingController(); - final deptEditController = TextEditingController(); - final roleEditController = TextEditingController(); - final nickNameEditController = TextEditingController(); - final userInfo = Rxn(); + final deptEditController = TextEditingController(); + final nicknameEditController = TextEditingController(); + final usernameEditController = TextEditingController(); + final phoneEditController = TextEditingController(); + final emailEditController = TextEditingController(); + final userInfo = Rx(UserInfoModel()); + final RxList> roles = RxList([]); + final RxBool loading = false.obs; + final List> roleSelection = []; + int? deptId; + final filePath = ''.obs; @override onReady() { - getUserInfo(); + init(); super.onReady(); } + init() async { + loading.value = true; + await Future.wait([getUserInfo(), getRoles()]); + loading.value = false; + } + + Future submit() async { + Map data = { + 'nickname': nicknameEditController.text, + 'deptId': deptId, + 'username': usernameEditController.text, + 'phone': phoneEditController.text, + 'roleIds': roleSelection.map((e) => e.value).toList() + }; + if (nicknameEditController.text.isEmpty) { + SnackBarUtil().error( + '姓名不能为空', + ); + return; + } + if (deptId == null) { + SnackBarUtil().error( + '部门不能为空', + ); + return; + } + + if (usernameEditController.text.isEmpty) { + SnackBarUtil().error( + '登录名不能为空', + ); + return; + } + if (roleSelection.map((e) => e.value).toList().isEmpty) { + SnackBarUtil().error( + '角色至少选择一个基础员工角色', + ); + return; + } + await LoadingUtil.to.show(status: '保存中请稍后...'); + try { + if (filePath.value.isNotEmpty) { + // 批量同时上传 + final res = await MediaUtil().uploadImg(File(filePath.value)); + if (res != null) { + data['avatar'] = res.path; + } + } + + await Api.updateUserInfo(userInfo.value!.id!, data); + final resUser = await Api.getUserInfo(userInfo.value!.id!); + if (resUser.data != null) { + userInfo.value = UserInfoModel.fromJson(resUser.data); + } + Get.back(result: userInfo.value); + SnackBarUtil().success( + '保存成功', + ); + } catch (e) { + LoggerUtil().error(e); + } finally { + LoadingUtil.to.dismiss(); + } + } + + Future getRoles() async { + try { + await Future.delayed(const Duration(milliseconds: 500)); + final res = + await Api.getRoles({'page': 1, 'pageSize': 30, 'useForSelect': 1}); + if (res.data != null) { + List> result = res.data!.items.map>((e) { + RoleModel data = RoleModel.fromJson(e); + return ValueItem( + label: data.name, value: data.id!, subLabel: data.remark); + }).toList(); + final rootRole = userInfo.value.roles + .firstWhereOrNull((element) => element.id == ROOT_ROLE_ID); + if (rootRole != null) { + result.add(ValueItem( + label: rootRole.name, + value: rootRole.id, + subLabel: rootRole.remark)); + } + + roles.assignAll(result); + } + } catch (e) { + LoggerUtil().error(e); + } + } + Future getUserInfo() async { try { final response = await Api.getUserInfo(userId); if (response.data != null) { userInfo.value = UserInfoModel.fromJson(response.data); - nameEditController.text = userInfo.value?.nickname ?? ''; + nicknameEditController.text = userInfo.value.nickname ?? ''; + usernameEditController.text = userInfo.value.username ?? ''; + deptEditController.text = userInfo.value.dept?.name ?? ''; + phoneEditController.text = userInfo.value.phone ?? ''; + emailEditController.text = userInfo.value.email ?? ''; + deptId = userInfo.value.dept?.id; + roleSelection.assignAll(userInfo.value!.roles + .map>((data) => ValueItem( + label: data.name, + value: data.id!, + )) + .toList()); } } catch (e) { SnackBarUtil().error('$e'); } finally {} } + + Future photoPicker() async { + XFile? pickedFile = await MediaUtil().getImageFromGallery(); + if (pickedFile != null) { + filePath.value = pickedFile.path; + } + } } diff --git a/lib/screens/hr_manage/hr_manage.dart b/lib/screens/hr_manage/hr_manage.dart index 69007ef..651ded2 100644 --- a/lib/screens/hr_manage/hr_manage.dart +++ b/lib/screens/hr_manage/hr_manage.dart @@ -8,7 +8,7 @@ import 'package:sk_base_mobile/constants/bg_color.dart'; import 'package:sk_base_mobile/constants/constants.dart'; import 'package:sk_base_mobile/models/user_info.model.dart'; import 'package:sk_base_mobile/screens/hr_manage/components/edit_userinfo.dart'; -import 'package:sk_base_mobile/util/common.util.dart'; + import 'package:sk_base_mobile/util/date.util.dart'; import 'package:sk_base_mobile/util/debouncer.dart'; import 'package:sk_base_mobile/util/device.util.dart'; @@ -18,7 +18,7 @@ import 'package:sk_base_mobile/util/screen_adaper_util.dart'; import 'package:sk_base_mobile/util/snack_bar.util.dart'; import 'package:sk_base_mobile/widgets/core/sk_ink.dart'; import 'package:sk_base_mobile/widgets/core/sk_tag.dart'; -import 'package:sk_base_mobile/widgets/core/sk_text_input.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_text_input.dart'; import 'package:sk_base_mobile/widgets/empty.dart'; import 'package:sk_base_mobile/widgets/fade_in_cache_image.dart'; import 'package:sk_base_mobile/widgets/loading_indicator.dart'; @@ -103,6 +103,7 @@ class HrManagePage extends StatelessWidget { child: SizedBox( height: ScreenAdaper.height(70), child: SkTextInput( + fillColor: AppTheme.inputFillColor, textController: controller.searchBarTextConroller, onChanged: (value) => doSearch(value), floatingLabelBehavior: FloatingLabelBehavior.never, @@ -243,10 +244,15 @@ class HrManagePage extends StatelessWidget { /// 编辑 buildActionButton( onTap: () { - ModalUtil.showGeneralDialog( + ModalUtil.showGeneralDialog( content: EditUserInfo( userId: controller.list[index].id!, - )).then((value) => Get.delete()); + )).then((value) { + Get.delete(); + if (value != null) { + controller.list[index] = value; + } + }); // Get.toNamed(RouteConfig.employeeDetail, // arguments: controller.list[index]); diff --git a/lib/screens/inventory/inventory.dart b/lib/screens/inventory/inventory.dart index 1845824..4e968c1 100644 --- a/lib/screens/inventory/inventory.dart +++ b/lib/screens/inventory/inventory.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:sk_base_mobile/app_theme.dart'; import 'package:sk_base_mobile/screens/new_inventory_inout/components/inventory_search.dart'; import 'package:sk_base_mobile/widgets/core/sk_appbar.dart'; @@ -10,6 +11,7 @@ class InventoryPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: SkAppbar(title: '库存管理', hideLeading: isPage), + backgroundColor: AppTheme.nearlyWhite, body: InventorySearch(), ); } diff --git a/lib/screens/inventory_inout/inventory_inout_controller.dart b/lib/screens/inventory_inout/inventory_inout_controller.dart index fe990b9..c573843 100644 --- a/lib/screens/inventory_inout/inventory_inout_controller.dart +++ b/lib/screens/inventory_inout/inventory_inout_controller.dart @@ -237,11 +237,10 @@ class InventoryInoutController extends GetxController { // 出入库详情dialog Future showInventoryInoutInfoDialog(int id) async { ModalUtil.showGeneralDialog( - width: ScreenAdaper.screenShortDistance() - ScreenAdaper.width(100), - height: Get.height - 100 < 400 ? 400 : Get.height - 100, - content: InventoryInoutInfo(inventoryInoutId: id), - offset: const Offset(0, -1)) - .then((value) => {Get.delete()}); + width: ScreenAdaper.screenShortDistance() - ScreenAdaper.width(100), + height: Get.height - 100 < 400 ? 400 : Get.height - 100, + content: InventoryInoutInfo(inventoryInoutId: id), + ).then((value) => {Get.delete()}); } Future> getInoutHistory() async { diff --git a/lib/screens/mine/useinfo/userinfo.controller.dart b/lib/screens/mine/useinfo/userinfo.controller.dart index 09181fa..2e5e7d9 100644 --- a/lib/screens/mine/useinfo/userinfo.controller.dart +++ b/lib/screens/mine/useinfo/userinfo.controller.dart @@ -3,17 +3,17 @@ import 'package:get/get.dart'; import 'package:sk_base_mobile/store/auth.store.dart'; class UserInfoController extends GetxController { - final nickNameController = TextEditingController(text: ''); + final nicknameController = TextEditingController(text: ''); @override void onReady() async { - nickNameController.text = AuthStore.to.userInfo.value.nickname ?? ''; + nicknameController.text = AuthStore.to.userInfo.value.nickname ?? ''; super.onReady(); } Future saveUserInfo() async { Map data = { - 'nickname': nickNameController.text, + 'nickname': nicknameController.text, }; await AuthStore.to.saveUserInfo(data); } diff --git a/lib/screens/mine/useinfo/userinfo.dart b/lib/screens/mine/useinfo/userinfo.dart index cf6b6e7..773bc06 100644 --- a/lib/screens/mine/useinfo/userinfo.dart +++ b/lib/screens/mine/useinfo/userinfo.dart @@ -46,7 +46,7 @@ class UserInfoPage extends StatelessWidget { child: MyAvatarWidget(), ), TextFormField( - controller: _controller.nickNameController, + controller: _controller.nicknameController, cursorColor: const Color.fromARGB(255, 87, 86, 86), style: TextStyle(fontSize: ScreenAdaper.height(18), color: Colors.black), diff --git a/lib/screens/new_inventory_inout/components/product_search.dart b/lib/screens/new_inventory_inout/components/product_search.dart index 2d75542..a3cd693 100644 --- a/lib/screens/new_inventory_inout/components/product_search.dart +++ b/lib/screens/new_inventory_inout/components/product_search.dart @@ -15,7 +15,7 @@ class ProductSearch extends StatelessWidget { ProductSearch({super.key, this.onProductSelected}); final controller = Get.put(ProductSearchController()); final listTitleTextStyle = - TextStyle(fontSize: ScreenAdaper.height(20), fontWeight: FontWeight.w600); + TextStyle(fontSize: ScreenAdaper.height(30), fontWeight: FontWeight.w600); @override Widget build(BuildContext context) { return Container( @@ -121,7 +121,7 @@ class ProductSearch extends StatelessWidget { BoxConstraints(minWidth: ScreenAdaper.width(100)), child: Text( itemData.productNumber!, - style: TextStyle(fontSize: ScreenAdaper.height(20)), + style: TextStyle(fontSize: ScreenAdaper.height(25)), ), ), SizedBox( @@ -133,12 +133,12 @@ class ProductSearch extends StatelessWidget { children: [ Text( '${itemData.name}', - style: TextStyle(fontSize: ScreenAdaper.height(20)), + style: TextStyle(fontSize: ScreenAdaper.height(28)), ), Text( '${itemData.company?.name}', style: TextStyle( - fontSize: ScreenAdaper.height(15), + fontSize: ScreenAdaper.height(18), color: AppTheme.grey), ) ], @@ -150,7 +150,9 @@ class ProductSearch extends StatelessWidget { //最小宽度 constraints: BoxConstraints(minWidth: ScreenAdaper.width(100)), - child: Text(itemData.productSpecification ?? ''), + child: Text(itemData.productSpecification ?? '', + style: + TextStyle(fontSize: ScreenAdaper.height(25))), ) ], ), diff --git a/lib/screens/new_inventory_inout/new_inventory_inout.dart b/lib/screens/new_inventory_inout/new_inventory_inout.dart index bbd1050..58dc7ae 100644 --- a/lib/screens/new_inventory_inout/new_inventory_inout.dart +++ b/lib/screens/new_inventory_inout/new_inventory_inout.dart @@ -13,10 +13,10 @@ import 'package:sk_base_mobile/screens/new_inventory_inout/components/inventory_ import 'package:sk_base_mobile/screens/new_inventory_inout/components/product_search.dart'; import 'package:sk_base_mobile/store/dict.store.dart'; import 'package:sk_base_mobile/util/util.dart'; -import 'package:sk_base_mobile/widgets/core/sk_number_input.dart'; -import 'package:sk_base_mobile/widgets/core/sk_search_select.dart'; -import 'package:sk_base_mobile/widgets/core/sk_date_picker.dart'; -import 'package:sk_base_mobile/widgets/core/sk_text_input.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_number_input.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_search_select.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_date_picker.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_text_input.dart'; import 'package:sk_base_mobile/widgets/gradient_button.dart'; import 'package:sk_base_mobile/screens/new_inventory_inout/new_inventory_inout_controller.dart'; import 'package:sk_base_mobile/widgets/core/sk_appbar.dart'; @@ -32,26 +32,31 @@ class NewInventoryInout extends StatelessWidget { Widget build(BuildContext context) { return SafeArea( top: false, // 设置为false以避免保留顶部状态栏的空间 - child: Scaffold( - appBar: SkAppbar( - title: inOrOut == InventoryInOrOutEnum.In ? '入库登记' : '出库登记', - ), - resizeToAvoidBottomInset: true, - body: SingleChildScrollView( - child: Container( - decoration: const BoxDecoration( - color: Colors.white, + child: GestureDetector( + onTap: () { + FocusScope.of(context).requestFocus(FocusNode()); + }, + child: Scaffold( + appBar: SkAppbar( + title: inOrOut == InventoryInOrOutEnum.In ? '入库登记' : '出库登记', ), - child: Column( - children: [ - buildForm(), - GradientButton( - buttonText: TextEnum.createInventoryInOutBtnText, - onPressed: () => {controller.create()}, - ) - ], - ), - )))); + resizeToAvoidBottomInset: true, + body: SingleChildScrollView( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + ), + child: Column( + children: [ + buildForm(), + GradientButton( + buttonText: TextEnum.createInventoryInOutBtnText, + onPressed: () => {controller.create()}, + ) + ], + ), + ))), + )); } Widget buildForm() { diff --git a/lib/screens/sale_quotation/sale_quotation.controller.dart b/lib/screens/sale_quotation/sale_quotation.controller.dart index fad7411..c4ba64b 100644 --- a/lib/screens/sale_quotation/sale_quotation.controller.dart +++ b/lib/screens/sale_quotation/sale_quotation.controller.dart @@ -8,7 +8,7 @@ import 'package:sk_base_mobile/models/base_search_more_controller.dart'; import 'package:sk_base_mobile/models/sale_quotation.model.dart'; import 'package:sk_base_mobile/screens/sale_quotation/components/sale_quotation_group_search.dart'; import 'package:sk_base_mobile/services/storage.service.dart'; -import 'package:sk_base_mobile/widgets/core/sk_muti_search_more.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_multi_search_more.dart'; import 'package:sk_base_mobile/util/modal.util.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; import 'package:pinyin/pinyin.dart'; diff --git a/lib/screens/sale_quotation/sale_quotation.dart b/lib/screens/sale_quotation/sale_quotation.dart index b396bc4..4e82895 100644 --- a/lib/screens/sale_quotation/sale_quotation.dart +++ b/lib/screens/sale_quotation/sale_quotation.dart @@ -9,8 +9,8 @@ import 'package:sk_base_mobile/screens/sale_quotation/sale_quotation.controller. import 'package:sk_base_mobile/util/modal.util.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; import 'package:sk_base_mobile/util/snack_bar.util.dart'; -import 'package:sk_base_mobile/widgets/core/sk_number_input.dart'; -import 'package:sk_base_mobile/widgets/core/sk_text_input.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_number_input.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_text_input.dart'; import 'package:sk_base_mobile/widgets/empty.dart'; import 'package:sk_base_mobile/widgets/core/sk_appbar.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; diff --git a/lib/screens/workbench/workbench.dart b/lib/screens/workbench/workbench.dart index ca97c52..6196639 100644 --- a/lib/screens/workbench/workbench.dart +++ b/lib/screens/workbench/workbench.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; +import 'package:sk_base_mobile/app_theme.dart'; import 'package:sk_base_mobile/constants/router.dart'; import 'package:sk_base_mobile/screens/workbench/workbench_controller.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; @@ -16,6 +17,7 @@ class WorkBenchPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: AppTheme.nearlyWhite, appBar: const SkAppbar(title: '工作台', hideLeading: true), body: buildList()); } diff --git a/lib/services/dio.service.dart b/lib/services/dio.service.dart index 727150c..e0f4dd7 100644 --- a/lib/services/dio.service.dart +++ b/lib/services/dio.service.dart @@ -118,10 +118,10 @@ class DioService extends get_package.GetxService { options.headers['model'] = StorageService.to .getString(CacheKeys.deviceModel, isWithUser: false); // 设备型号 } - // if (GloablConfig.DEBUG && (options.data is! FormData)) { - // LoggerUtil().info( - // '[Service-dio] url: ${options.path}, params: ${jsonEncode(options.queryParameters)}, body: ${jsonEncode(options.data)}, Header:${jsonEncode(options.headers)}'); - // } + if (GloablConfig.DEBUG && (options.data is! FormData)) { + LoggerUtil().info( + '[Service-dio] url: ${options.path}, params: ${jsonEncode(options.queryParameters)}, body: ${jsonEncode(options.data)}, Header:${jsonEncode(options.headers)}'); + } handler.next(options); } @@ -135,7 +135,7 @@ class DioService extends get_package.GetxService { } if (response.data != null && response.data is Map) { if (response.data['code'] == 200) { - // if (GloablConfig.DEBUG) LoggerUtil().info(response.data['data']); + if (GloablConfig.DEBUG) LoggerUtil().info(response.data['data']); response.data = response.data['data']; // 分页数据处理 if (response.data != null && diff --git a/lib/util/media_util.dart b/lib/util/media_util.dart index dfb6b11..16e4f05 100644 --- a/lib/util/media_util.dart +++ b/lib/util/media_util.dart @@ -10,9 +10,9 @@ import 'package:sk_base_mobile/models/upload_result.model.dart'; import 'package:sk_base_mobile/services/service.dart'; class MediaUtil { - static String? getMediaUrl(String? url) { + static String getMediaUrl(String? url) { if ((url ?? '').isEmpty) { - return null; + return ''; } else { return '${GloablConfig.OSS_URL}$url'; } @@ -45,9 +45,8 @@ class MediaUtil { return null; } AppInfoService.to.isCameraing.value = true; - XFile? pickedFile = await ImagePicker().pickImage( - source: ImageSource.gallery, - ); + XFile? pickedFile = await ImagePicker() + .pickImage(source: ImageSource.gallery, imageQuality: 60); AppInfoService.to.isCameraing.value = false; return pickedFile; } diff --git a/lib/util/modal.util.dart b/lib/util/modal.util.dart index 4db115b..41bbc7e 100644 --- a/lib/util/modal.util.dart +++ b/lib/util/modal.util.dart @@ -161,25 +161,40 @@ class ModalUtil { }, ); - static Future showGeneralDialog( + static Future showGeneralDialog( {required Widget content, double? width, double? height, Offset? offset}) { - return Get.generalDialog( + return Get.generalDialog( barrierLabel: "generalDialog", barrierDismissible: true, transitionDuration: const Duration(milliseconds: 400), pageBuilder: (_, __, ___) { - return Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(ScreenAdaper.sp(30)), - child: Material( - child: SizedBox( - height: height ?? Get.height - ScreenAdaper.height(150), - width: width ?? Get.width - ScreenAdaper.width(150), - child: content, - )))); + return SafeArea( + child: ScreenAdaper.isTablet() + ? Center( + child: ClipRRect( + borderRadius: + BorderRadius.circular(ScreenAdaper.sp(30)), + child: Material( + child: SizedBox( + height: + height ?? Get.height - ScreenAdaper.height(150), + width: width ?? Get.width - ScreenAdaper.width(150), + child: content, + ), + ))) + : Container( + margin: EdgeInsets.only(top: Get.height / 10), + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(ScreenAdaper.sp(30)), + topRight: Radius.circular(ScreenAdaper.sp(30))), + child: Material( + child: content, + ), + ))); }, transitionBuilder: (_, anim, __, child) { Tween tween; diff --git a/lib/widgets/common/multi-picker/enum/app_enums.dart b/lib/widgets/common/multi-picker/enum/app_enums.dart new file mode 100644 index 0000000..4a7a8e6 --- /dev/null +++ b/lib/widgets/common/multi-picker/enum/app_enums.dart @@ -0,0 +1,23 @@ +/// [SelectionType] +/// SelectionType enum for the selection type of the dropdown items. +/// * [single]: single selection +/// * [multi]: multi selection +enum SelectionType { + single, + multi, +} + +/// [WrapType] +/// WrapType enum for the wrap type of the selected items. +/// * [WrapType.scroll]: scroll the selected items horizontally +/// * [WrapType.wrap]: wrap the selected items in both directions +enum WrapType { scroll, wrap } + +/// [RequestMethod] +/// RequestMethod enum for the request method of the dropdown items. +/// * [RequestMethod.get]: get request +/// * [RequestMethod.post]: post request +/// * [RequestMethod.put]: put request +/// * [RequestMethod.delete]: delete request +/// * [RequestMethod.patch]: patch request +enum RequestMethod { get, post, put, patch, delete } diff --git a/lib/widgets/common/multi-picker/models/chip_config.dart b/lib/widgets/common/multi-picker/models/chip_config.dart new file mode 100644 index 0000000..82ac1d2 --- /dev/null +++ b/lib/widgets/common/multi-picker/models/chip_config.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import '../enum/app_enums.dart'; + +/// Configuration for the chip. +/// [backgroundColor] is the background color of the chip. Defaults to [Colors.white]. +/// [padding] is the padding of the chip. +/// [radius] is the radius of the chip. Defaults to [BorderRadius.circular(18)]. +/// +/// [labelStyle] is the style of the label. +/// [labelPadding] is the padding of the label. +/// +/// [deleteIcon] is the icon that is used to delete the chip. +/// [deleteIconColor] is the color of the delete icon. +/// +/// [separator] is the separator between the chips. Default is a sized box with width of 8. +/// [spacing] is the width of the separator. If separator is provided, this value is ignored. +/// +/// [wrapType] is the type of the chip. Default is [WrapType.scroll]. [WrapType.wrap] will wrap the chips to next line if there is not enough space. [WrapType.scroll] will scroll the chips. +/// * [WrapType.scroll] is used to scroll the chips. +/// * [WrapType.wrap] is used to wrap the chips in a row. +/// +/// +/// +/// +/// An example of a [ChipConfig] is: +/// ```dart +/// const ChipConfig( +/// deleteIcon: Icon(Icons.delete, color: Colors.red), +/// wrapType: WrapType.scroll, +/// separator: const Divider(), +/// padding: const EdgeInsets.all(8), +/// labelStyle: TextStyle(fontSize: 16), +/// labelPadding: const EdgeInsets.symmetric(horizontal: 8), +/// radius: BorderRadius.circular(18), +/// backgroundColor: Colors.white, +/// ) +/// ``` + +class ChipConfig { + final Icon? deleteIcon; + + final Color deleteIconColor; + final Color labelColor; + final Color? backgroundColor; + + final TextStyle? labelStyle; + final EdgeInsets padding; + final EdgeInsets labelPadding; + + final double radius; + final double spacing; + final double runSpacing; + + final Widget? separator; + + final WrapType wrapType; + + final bool autoScroll; + + const ChipConfig({ + this.deleteIcon, + this.deleteIconColor = Colors.white, + this.backgroundColor, + this.padding = const EdgeInsets.only(left: 12, top: 0, right: 4, bottom: 0), + this.radius = 18, + this.spacing = 8, + this.runSpacing = 8, + this.separator, + this.labelColor = Colors.white, + this.labelStyle, + this.wrapType = WrapType.scroll, + this.labelPadding = EdgeInsets.zero, + this.autoScroll = false, + }); +} diff --git a/lib/widgets/common/multi-picker/models/network_config.dart b/lib/widgets/common/multi-picker/models/network_config.dart new file mode 100644 index 0000000..1081953 --- /dev/null +++ b/lib/widgets/common/multi-picker/models/network_config.dart @@ -0,0 +1,25 @@ +import '../enum/app_enums.dart'; + +/// Configuration for the network. +/// +/// [url] is the url of the network. +/// [method] is the request method of the network. +/// [headers] is the headers of the network. +/// [body] is the body of the network. +/// [queryParameters] is the query parameters of the network. + +class NetworkConfig { + final String url; + final RequestMethod method; + final Map? headers; + final Map? body; + final Map? queryParameters; + + NetworkConfig({ + required this.url, + this.method = RequestMethod.get, + this.headers = const {}, + this.body, + this.queryParameters = const {}, + }); +} diff --git a/lib/widgets/common/multi-picker/models/value_item.dart b/lib/widgets/common/multi-picker/models/value_item.dart new file mode 100644 index 0000000..f92bf5e --- /dev/null +++ b/lib/widgets/common/multi-picker/models/value_item.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +/// [label] is the item that is displayed in the list. [value] is the value that is returned when the item is selected. +/// If the [value] is not provided, the [label] is used as the value. +/// subLabel is the subtitle that is displayed in the list. +/// An example of a [ValueItem] is: +/// ```dart +/// const ValueItem(label: 'Option 1', value: '1') +/// ``` + +class ValueItem { + /// The label of the value item + final String label; + final String? subLabel; + + /// The value of the value item + final T? value; + + /// Default constructor for [ValueItem] + const ValueItem({required this.label, required this.value, this.subLabel}); + + /// toString method for [ValueItem] + @override + String toString() { + return 'ValueItem(label: $label, value: $value, subLabel: $subLabel)'; + } + + /// toMap method for [ValueItem] + Map toMap() { + return {'label': label, 'value': value, 'subLabel': subLabel}; + } + + /// fromMap method for [ValueItem] + factory ValueItem.fromMap(Map map) { + return ValueItem( + label: map['label'] ?? '', + value: map['value'], + subLabel: map['subLabel'] ?? ''); + } + + /// toJson method for [ValueItem] + String toJson() => json.encode(toMap()); + + /// fromJson method for [ValueItem] + factory ValueItem.fromJson(String source) => + ValueItem.fromMap(json.decode(source)); + + /// Equality operator for [ValueItem] + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ValueItem && other.value == value; + } + + /// Hashcode for [ValueItem] + @override + int get hashCode => label.hashCode ^ value.hashCode ^ subLabel.hashCode; + + /// CopyWith method for [ValueItem] + ValueItem copyWith({ + String? label, + T? value, + }) { + return ValueItem( + label: label ?? this.label, + value: value ?? this.value, + subLabel: subLabel ?? this.subLabel); + } +} diff --git a/lib/widgets/common/multi-picker/multiselect_dropdown.dart b/lib/widgets/common/multi-picker/multiselect_dropdown.dart new file mode 100644 index 0000000..81e1cf5 --- /dev/null +++ b/lib/widgets/common/multi-picker/multiselect_dropdown.dart @@ -0,0 +1,1269 @@ +library multiselect_dropdown; + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:sk_base_mobile/app_theme.dart'; +import 'package:sk_base_mobile/util/screen_adaper_util.dart'; +import './models/network_config.dart'; +import './widgets/hint_text.dart'; +import './widgets/selection_chip.dart'; +import './widgets/single_selected_item.dart'; +import 'package:http/http.dart' as http; + +import 'models/chip_config.dart'; +import 'models/value_item.dart'; +import 'enum/app_enums.dart'; + +export 'enum/app_enums.dart'; +export 'models/chip_config.dart'; +export 'models/value_item.dart'; +export 'models/network_config.dart'; + +typedef OnOptionSelected = void Function(List> selectedOptions); + +class MultiSelectDropDown extends StatefulWidget { + // selection type of the dropdown + final SelectionType selectionType; + + // Hint + final String hint; + final Color? hintColor; + final double? hintFontSize; + final TextStyle? hintStyle; + final EdgeInsetsGeometry? hintPadding; + + // Options + final List> options; + final List> selectedOptions; + final List> disabledOptions; + final OnOptionSelected? onOptionSelected; + + /// [onOptionRemoved] is the callback that is called when an option is removed.The callback takes two arguments, the index of the removed option and the removed option. + /// This will be called only when the delete icon is clicked on the option chip. + /// + /// This will not be called when the option is removed programmatically. + /// + /// ```index``` is the index of the removed option. + /// + /// ```option``` is the removed option. + final void Function(int index, ValueItem option)? onOptionRemoved; + + // selected option + final Icon? selectedOptionIcon; + final Color? selectedOptionTextColor; + final Color? selectedOptionBackgroundColor; + final Widget Function(BuildContext, ValueItem)? selectedItemBuilder; + + // chip configuration + final bool showChipInSingleSelectMode; + final ChipConfig chipConfig; + + // options configuration + final Color? optionsBackgroundColor; + final TextStyle? optionTextStyle; + final double dropdownHeight; + final Widget? optionSeparator; + final bool alwaysShowOptionIcon; + + /// option builder + /// [optionBuilder] is the builder that is used to build the option item. + /// The builder takes three arguments, the context, the option and the selected status of the option. + /// The builder returns a widget. + /// + + final Widget Function(BuildContext ctx, ValueItem item, bool selected)? + optionBuilder; + + // dropdownfield configuration + final Color? fieldBackgroundColor; + final Icon suffixIcon; + final bool animateSuffixIcon; + final Icon? clearIcon; + final Decoration? inputDecoration; + final double? borderRadius; + final BorderRadiusGeometry? radiusGeometry; + final Color? borderColor; + final Color? focusedBorderColor; + final double? borderWidth; + final double? focusedBorderWidth; + final EdgeInsets? padding; + + final TextStyle? singleSelectItemStyle; + + final int? maxItems; + + final Color? dropdownBackgroundColor; + final Color? searchBackgroundColor; + + // dropdown border radius + final double? dropdownBorderRadius; + final double? dropdownMargin; + + // network configuration + final NetworkConfig? networkConfig; + final Future>> Function(dynamic)? responseParser; + final Widget Function(BuildContext, dynamic)? responseErrorBuilder; + + /// focus node + final FocusNode? focusNode; + + /// Controller for the dropdown + /// [controller] is the controller for the dropdown. It can be used to programmatically open and close the dropdown. + final MultiSelectController? controller; + + /// Enable search + /// [searchEnabled] is the flag to enable search in dropdown. It is used to show search bar in dropdown. + final bool searchEnabled; + + /// Search label + /// [searchLabel] is the label for search bar in dropdown. + final String? searchLabel; + + /// MultiSelectDropDown is a widget that allows the user to select multiple options from a list of options. It is a dropdown that allows the user to select multiple options. + /// + /// **Selection Type** + /// + /// [selectionType] is the type of selection that the user can make. The default is [SelectionType.single]. + /// * [SelectionType.single] - allows the user to select only one option. + /// * [SelectionType.multi] - allows the user to select multiple options. + /// + /// **Options** + /// + /// [options] is the list of options that the user can select from. The options need to be of type [ValueItem]. + /// + /// [selectedOptions] is the list of options that are pre-selected when the widget is first displayed. The options need to be of type [ValueItem]. + /// + /// [disabledOptions] is the list of options that the user cannot select. The options need to be of type [ValueItem]. If the items in this list are not available in options, will be ignored. + /// + /// [onOptionSelected] is the callback that is called when an option is selected or unselected. The callback takes one argument of type `List`. + /// + /// **Selected Option** + /// + /// [selectedOptionIcon] is the icon that is used to indicate the selected option. + /// + /// [selectedOptionTextColor] is the color of the selected option. + /// + /// [selectedOptionBackgroundColor] is the background color of the selected option. + /// + /// [selectedItemBuilder] is the builder that is used to build the selected option. If this is not provided, the default builder is used. + /// + /// **Chip Configuration** + /// + /// [showChipInSingleSelectMode] is used to show the chip in single select mode. The default is false. + /// + /// [chipConfig] is the configuration for the chip. + /// + /// **Options Configuration** + /// + /// [optionsBackgroundColor] is the background color of the options. The default is [Colors.white]. + /// + /// [optionTextStyle] is the text style of the options. + /// + /// [optionSeparator] is the seperator between the options. + /// + /// [dropdownHeight] is the height of the dropdown options. The default is 200. + /// + /// **Dropdown Configuration** + /// + /// [fieldBackgroundColor] is the background color of the dropdown. The default is [Colors.white]. + /// + /// [suffixIcon] is the icon that is used to indicate the dropdown. The default is [Icons.arrow_drop_down]. + /// + /// [inputDecoration] is the decoration of the dropdown. + /// + /// [dropdownHeight] is the height of the dropdown. The default is 200. + /// + /// **Hint** + /// + /// [hint] is the hint text to be displayed when no option is selected. + /// + /// [hintColor] is the color of the hint text. The default is [Colors.grey.shade300]. + /// + /// [hintFontSize] is the font size of the hint text. The default is 14.0. + /// + /// [hintStyle] is the style of the hint text. + /// + /// [animateSuffixIcon] is the flag to enable animation for the suffix icon. The default is true. + /// + /// **Example** + /// + /// ```dart + /// final List options = [ + /// ValueItem(label: 'Option 1', value: '1'), + /// ValueItem(label: 'Option 2', value: '2'), + /// ValueItem(label: 'Option 3', value: '3'), + /// ]; + /// + /// final List selectedOptions = [ + /// ValueItem(label: 'Option 1', value: '1'), + /// ]; + /// + /// final List disabledOptions = [ + /// ValueItem(label: 'Option 2', value: '2'), + /// ]; + /// + /// MultiSelectDropDown( + /// onOptionSelected: (option) {}, + /// options: const [ + /// ValueItem(label: 'Option 1', value: '1'), + /// ValueItem(label: 'Option 2', value: '2'), + /// ValueItem(label: 'Option 3', value: '3'), + /// ValueItem(label: 'Option 4', value: '4'), + /// ValueItem(label: 'Option 5', value: '5'), + /// ValueItem(label: 'Option 6', value: '6'), + /// ], + /// selectionType: SelectionType.multi, + /// selectedOptions: selectedOptions, + /// disabledOptions: disabledOptions, + /// onOptionSelected: (List selectedOptions) { + /// debugPrint('onOptionSelected: $option'); + /// }, + /// chipConfig: const ChipConfig(wrapType: WrapType.scroll), + /// ); + /// ``` + + const MultiSelectDropDown( + {Key? key, + required this.onOptionSelected, + required this.options, + this.onOptionRemoved, + this.selectedOptionTextColor, + this.chipConfig = const ChipConfig(), + this.selectionType = SelectionType.multi, + this.hint = 'Select', + this.hintColor = Colors.grey, + this.hintFontSize = 14.0, + this.hintPadding = HintText.hintPaddingDefault, + this.selectedOptions = const [], + this.disabledOptions = const [], + this.alwaysShowOptionIcon = false, + this.optionTextStyle, + this.selectedOptionIcon = const Icon(Icons.check), + this.selectedOptionBackgroundColor, + this.optionsBackgroundColor, + this.fieldBackgroundColor = Colors.white, + this.dropdownHeight = 200, + this.showChipInSingleSelectMode = false, + this.suffixIcon = const Icon(Icons.arrow_drop_down), + this.clearIcon = const Icon(Icons.close_outlined, size: 20), + this.selectedItemBuilder, + this.optionSeparator, + this.inputDecoration, + this.hintStyle, + this.padding, + this.focusedBorderColor = Colors.black54, + this.borderColor = Colors.grey, + this.borderWidth = 0.4, + this.focusedBorderWidth = 0.4, + this.borderRadius = 12.0, + this.radiusGeometry, + this.maxItems, + this.focusNode, + this.controller, + this.searchEnabled = false, + this.dropdownBorderRadius, + this.dropdownMargin, + this.dropdownBackgroundColor, + this.searchBackgroundColor, + this.animateSuffixIcon = true, + this.singleSelectItemStyle, + this.optionBuilder, + this.searchLabel = 'Search'}) + : networkConfig = null, + responseParser = null, + responseErrorBuilder = null, + super(key: key); + + /// Constructor for MultiSelectDropDown that fetches the options from a network call. + /// [networkConfig] is the configuration for the network call. + /// [responseParser] is the parser that is used to parse the response from the network call. + /// [responseErrorBuilder] is the builder that is used to build the error widget when the network call fails. + + const MultiSelectDropDown.network( + {Key? key, + required this.onOptionSelected, + required this.networkConfig, + required this.responseParser, + this.onOptionRemoved, + this.responseErrorBuilder, + this.selectedOptionTextColor, + this.chipConfig = const ChipConfig(), + this.selectionType = SelectionType.multi, + this.hint = 'Select', + this.hintColor = Colors.grey, + this.hintFontSize = 14.0, + this.selectedOptions = const [], + this.disabledOptions = const [], + this.alwaysShowOptionIcon = false, + this.optionTextStyle, + this.selectedOptionIcon = const Icon(Icons.check), + this.selectedOptionBackgroundColor, + this.optionsBackgroundColor, + this.fieldBackgroundColor = Colors.white, + this.dropdownHeight = 200, + this.showChipInSingleSelectMode = false, + this.suffixIcon = const Icon(Icons.arrow_drop_down), + this.clearIcon = const Icon(Icons.close_outlined, size: 14), + this.selectedItemBuilder, + this.optionSeparator, + this.inputDecoration, + this.hintStyle, + this.hintPadding = HintText.hintPaddingDefault, + this.padding, + this.borderColor = Colors.grey, + this.focusedBorderColor = Colors.black54, + this.borderWidth = 0.4, + this.focusedBorderWidth = 0.4, + this.borderRadius = 12.0, + this.radiusGeometry, + this.maxItems, + this.focusNode, + this.controller, + this.searchEnabled = false, + this.dropdownBorderRadius, + this.dropdownMargin, + this.dropdownBackgroundColor, + this.searchBackgroundColor, + this.animateSuffixIcon = true, + this.singleSelectItemStyle, + this.optionBuilder, + this.searchLabel = 'Search'}) + : options = const [], + super(key: key); + + @override + State> createState() => _MultiSelectDropDownState(); +} + +class _MultiSelectDropDownState extends State> { + /// Options list that is used to display the options. + final List> _options = []; + + /// Selected options list that is used to display the selected options. + final List> _selectedOptions = []; + + /// Disabled options list that is used to display the disabled options. + final List> _disabledOptions = []; + + /// The controller for the dropdown. + OverlayState? _overlayState; + OverlayEntry? _overlayEntry; + bool _selectionMode = false; + + late final FocusNode _focusNode; + final LayerLink _layerLink = LayerLink(); + + /// Response from the network call. + dynamic _reponseBody; + + /// value notifier that is used for controller. + late MultiSelectController _controller; + + /// search field focus node + FocusNode? _searchFocusNode; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _initialize(); + }); + _focusNode = widget.focusNode ?? FocusNode(); + _controller = widget.controller ?? MultiSelectController(); + } + + /// Initializes the options, selected options and disabled options. + /// If the options are fetched from the network, then the network call is made. + /// If the options are passed as a parameter, then the options are initialized. + void _initialize() async { + if (!mounted) return; + if (widget.networkConfig?.url != null) { + await _fetchNetwork(); + } else { + _options.addAll(_controller.options.isNotEmpty == true + ? _controller.options + : widget.options); + } + _addOptions(); + if (mounted) { + _initializeOverlay(); + } else { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _initializeOverlay(); + }); + } + } + + void _initializeOverlay() { + _overlayState ??= Overlay.of(context); + + _focusNode.addListener(_handleFocusChange); + + if (widget.searchEnabled) { + _searchFocusNode = FocusNode(); + _searchFocusNode!.addListener(_handleFocusChange); + } + } + + /// Adds the selected options and disabled options to the options list. + void _addOptions() { + setState(() { + _selectedOptions.addAll(_controller.selectedOptions.isNotEmpty == true + ? _controller.selectedOptions + : widget.selectedOptions); + _disabledOptions.addAll(_controller.disabledOptions.isNotEmpty == true + ? _controller.disabledOptions + : widget.disabledOptions); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_controller._isDisposed == false) { + _controller.setOptions(_options); + _controller.setSelectedOptions(_selectedOptions); + _controller.setDisabledOptions(_disabledOptions); + + _controller.addListener(_handleControllerChange); + } + }); + } + + /// Handles the focus change to show/hide the dropdown. + void _handleFocusChange() { + if (_focusNode.hasFocus && mounted) { + _overlayEntry = _reponseBody != null && widget.networkConfig != null + ? _buildNetworkErrorOverlayEntry() + : _buildOverlayEntry(); + Overlay.of(context).insert(_overlayEntry!); + _updateSelection(); + return; + } + + if ((_searchFocusNode == null || _searchFocusNode?.hasFocus == false) && + _overlayEntry != null) { + _overlayEntry?.remove(); + } + + if (mounted) _updateSelection(); + + _controller.value._isDropdownOpen = + _focusNode.hasFocus || _searchFocusNode?.hasFocus == true; + } + + void _updateSelection() { + setState(() { + _selectionMode = + _focusNode.hasFocus || _searchFocusNode?.hasFocus == true; + }); + } + + /// Calculate offset size for dropdown. + List _calculateOffsetSize() { + RenderBox? renderBox = context.findRenderObject() as RenderBox?; + + var size = renderBox?.size ?? Size.zero; + var offset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + + final availableHeight = MediaQuery.of(context).size.height - offset.dy; + + return [size, availableHeight < widget.dropdownHeight]; + } + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: true, + child: CompositedTransformTarget( + link: _layerLink, + child: Focus( + canRequestFocus: true, + skipTraversal: true, + focusNode: _focusNode, + child: InkWell( + splashColor: null, + splashFactory: null, + onTap: _toggleFocus, + child: Container( + height: widget.chipConfig.wrapType == WrapType.wrap ? null : 52, + constraints: BoxConstraints( + minWidth: MediaQuery.of(context).size.width, + minHeight: 52, + ), + padding: _getContainerPadding(), + decoration: _getContainerDecoration(), + child: Row( + children: [ + Expanded( + child: _getContainerContent(), + ), + if (widget.clearIcon != null && _anyItemSelected) ...[ + const SizedBox(width: 4), + InkWell( + onTap: () => clear(), + child: widget.clearIcon, + ), + const SizedBox(width: 4) + ], + _buildSuffixIcon(), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildSuffixIcon() { + if (widget.animateSuffixIcon) { + return AnimatedRotation( + turns: _selectionMode ? 0.5 : 0, + duration: const Duration(milliseconds: 300), + child: widget.suffixIcon, + ); + } + return widget.suffixIcon; + } + + /// Container Content for the dropdown. + Widget _getContainerContent() { + if (_selectedOptions.isEmpty) { + return HintText( + hintText: widget.hint, + hintColor: widget.hintColor, + hintStyle: widget.hintStyle, + hintPadding: widget.hintPadding, + ); + } + + if (widget.selectionType == SelectionType.single && + !widget.showChipInSingleSelectMode) { + return SingleSelectedItem( + label: _selectedOptions.first.label, + style: widget.singleSelectItemStyle); + } + + return _buildSelectedItems(); + } + + /// return true if any item is selected. + bool get _anyItemSelected => _selectedOptions.isNotEmpty; + + /// Container decoration for the dropdown. + Decoration _getContainerDecoration() { + return widget.inputDecoration ?? + BoxDecoration( + color: widget.fieldBackgroundColor ?? Colors.white, + borderRadius: widget.radiusGeometry ?? + BorderRadius.circular(widget.borderRadius ?? 12.0), + border: _selectionMode + ? Border.all( + color: widget.focusedBorderColor ?? Colors.grey, + width: widget.focusedBorderWidth ?? 0.4, + ) + : Border.all( + color: widget.borderColor ?? Colors.grey, + width: widget.borderWidth ?? 0.4, + ), + ); + } + + /// Dispose the focus node and overlay entry. + @override + void dispose() { + if (_overlayEntry?.mounted == true) { + if (_overlayState != null && _overlayEntry != null) { + _overlayEntry?.remove(); + } + _overlayEntry = null; + _overlayState?.dispose(); + } + _focusNode.removeListener(_handleFocusChange); + _searchFocusNode?.removeListener(_handleFocusChange); + _focusNode.dispose(); + _searchFocusNode?.dispose(); + _controller.removeListener(_handleControllerChange); + + if (widget.controller == null || widget.controller?.isDisposed == true) { + _controller.dispose(); + } + + super.dispose(); + } + + /// Build the selected items for the dropdown. + Widget _buildSelectedItems() { + if (widget.chipConfig.wrapType == WrapType.scroll) { + return ListView.separated( + separatorBuilder: (context, index) => + _getChipSeparator(widget.chipConfig), + scrollDirection: Axis.horizontal, + itemCount: _selectedOptions.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final option = _selectedOptions[index]; + if (widget.selectedItemBuilder != null) { + return widget.selectedItemBuilder!(context, option); + } + return _buildChip(option, widget.chipConfig); + }, + ); + } + return Wrap( + spacing: widget.chipConfig.spacing, + runSpacing: widget.chipConfig.runSpacing, + children: mapIndexed(_selectedOptions, (index, item) { + if (widget.selectedItemBuilder != null) { + return widget.selectedItemBuilder!( + context, _selectedOptions[index]); + } + return _buildChip(_selectedOptions[index], widget.chipConfig); + }).toList()); + } + + /// Util method to map with index. + Iterable mapIndexed( + Iterable items, E Function(int index, F item) f) sync* { + var index = 0; + + for (final item in items) { + yield f(index, item); + index = index + 1; + } + } + + /// Get the chip separator. + Widget _getChipSeparator(ChipConfig chipConfig) { + if (chipConfig.separator != null) { + return chipConfig.separator!; + } + + return SizedBox( + width: chipConfig.spacing, + ); + } + + /// Handle the focus change on tap outside of the dropdown. + void _onOutSideTap() { + if (_searchFocusNode != null) { + _searchFocusNode!.unfocus(); + } + _focusNode.unfocus(); + } + + /// Buid the selected item chip. + Widget _buildChip(ValueItem item, ChipConfig chipConfig) { + return SelectionChip( + item: item, + chipConfig: chipConfig, + onItemDelete: (removedItem) { + widget.onOptionRemoved?.call(_options.indexOf(removedItem), + _selectedOptions[_selectedOptions.indexOf(removedItem)]); + + _controller.clearSelection(removedItem); + if (_focusNode.hasFocus) _focusNode.unfocus(); + }, + ); + } + + /// Method to toggle the focus of the dropdown. + void _toggleFocus() { + if (_focusNode.hasFocus) { + _focusNode.unfocus(); + } else { + _focusNode.requestFocus(); + } + } + + /// Get the selectedItem icon for the dropdown + Widget? _getSelectedIcon(bool isSelected, Color primaryColor) { + if (isSelected) { + return widget.selectedOptionIcon ?? + Icon( + Icons.check, + color: primaryColor, + ); + } + if (!widget.alwaysShowOptionIcon) { + return null; + } + + final Icon icon = widget.selectedOptionIcon ?? + Icon( + Icons.check, + color: widget.optionTextStyle?.color ?? Colors.grey, + ); + + return icon; + } + + /// Create the overlay entry for the dropdown. + OverlayEntry _buildOverlayEntry() { + // Calculate the offset and the size of the dropdown button + final values = _calculateOffsetSize(); + // Get the size from the first item in the values list + final size = values[0] as Size; + // Get the showOnTop value from the second item in the values list + final showOnTop = values[1] as bool; + + return OverlayEntry(builder: (context) { + List> options = _options; + List> selectedOptions = [..._selectedOptions]; + final searchController = TextEditingController(); + + return StatefulBuilder(builder: ((context, dropdownState) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _onOutSideTap, + child: const ColoredBox( + color: Colors.transparent, + ), + ), + ), + CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: true, + targetAnchor: + showOnTop ? Alignment.topLeft : Alignment.bottomLeft, + followerAnchor: + showOnTop ? Alignment.bottomLeft : Alignment.topLeft, + offset: widget.dropdownMargin != null + ? Offset( + 0, + showOnTop + ? -widget.dropdownMargin! + : widget.dropdownMargin!) + : Offset.zero, + child: Material( + color: widget.dropdownBackgroundColor ?? Colors.white, + // borderRadius: widget.dropdownBorderRadius != null + // ? BorderRadius.circular(widget.dropdownBorderRadius!) + // : null, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(widget.dropdownBorderRadius ?? 0), + ), + ), + shadowColor: Colors.black, + child: Container( + decoration: BoxDecoration( + backgroundBlendMode: BlendMode.dstATop, + color: widget.dropdownBackgroundColor ?? Colors.white, + borderRadius: widget.dropdownBorderRadius != null + ? BorderRadius.circular(widget.dropdownBorderRadius!) + : null, + ), + constraints: widget.searchEnabled + ? BoxConstraints.loose( + Size(size.width, widget.dropdownHeight + 50)) + : BoxConstraints.loose( + Size(size.width, widget.dropdownHeight)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.searchEnabled) ...[ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: searchController, + onTapOutside: (_) {}, + scrollPadding: EdgeInsets.only( + bottom: + MediaQuery.of(context).viewInsets.bottom), + focusNode: _searchFocusNode, + decoration: InputDecoration( + fillColor: widget.searchBackgroundColor ?? + Colors.grey.shade200, + isDense: true, + filled: widget.searchBackgroundColor != null, + hintText: widget.searchLabel, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.shade300, + width: 0.8, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 0.8, + ), + ), + suffixIcon: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + searchController.clear(); + dropdownState(() { + options = _options; + }); + }, + ), + ), + onChanged: (value) { + dropdownState(() { + options = _options + .where((element) => element.label + .toLowerCase() + .contains(value.toLowerCase())) + .toList(); + }); + }, + ), + ), + const Divider( + height: 1, + ), + ], + Expanded( + child: ListView.separated( + separatorBuilder: (_, __) => + widget.optionSeparator ?? + const SizedBox(height: 0), + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (context, index) { + final option = options[index]; + final isSelected = + selectedOptions.contains(option); + + onTap() { + if (widget.selectionType == + SelectionType.multi) { + if (isSelected) { + dropdownState(() { + selectedOptions.remove(option); + }); + setState(() { + _selectedOptions.remove(option); + }); + } else { + final bool hasReachMax = + widget.maxItems == null + ? false + : (_selectedOptions.length + 1) > + widget.maxItems!; + if (hasReachMax) return; + + dropdownState(() { + selectedOptions.add(option); + }); + setState(() { + _selectedOptions.add(option); + }); + } + } else { + dropdownState(() { + selectedOptions.clear(); + selectedOptions.add(option); + }); + setState(() { + _selectedOptions.clear(); + _selectedOptions.add(option); + }); + _focusNode.unfocus(); + } + + _controller.value._selectedOptions.clear(); + _controller.value._selectedOptions + .addAll(_selectedOptions); + + widget.onOptionSelected?.call(_selectedOptions); + } + + if (widget.optionBuilder != null) { + return InkWell( + onTap: onTap, + child: widget.optionBuilder!( + context, option, isSelected), + ); + } + + final primaryColor = + Theme.of(context).primaryColor; + + return _buildOption( + option: option, + primaryColor: primaryColor, + isSelected: isSelected, + dropdownState: dropdownState, + onTap: onTap, + selectedOptions: selectedOptions, + ); + }, + ), + ), + ], + ), + )), + ), + ], + ); + })); + }); + } + + ListTile _buildOption( + {required ValueItem option, + required Color primaryColor, + required bool isSelected, + required StateSetter dropdownState, + required void Function() onTap, + required List> selectedOptions}) => + ListTile( + title: Text(option.label, + style: widget.optionTextStyle ?? + TextStyle( + fontSize: widget.hintFontSize, + )), + subtitle: option.subLabel == null + ? null + : Text(option.subLabel!, + style: TextStyle( + fontSize: ScreenAdaper.height(25), color: AppTheme.grey)), + selectedColor: widget.selectedOptionTextColor ?? primaryColor, + selected: isSelected, + autofocus: true, + dense: true, + tileColor: widget.optionsBackgroundColor ?? Colors.white, + selectedTileColor: + widget.selectedOptionBackgroundColor ?? Colors.grey.shade200, + enabled: !_disabledOptions.contains(option), + onTap: onTap, + trailing: _getSelectedIcon(isSelected, primaryColor)); + + /// Make a request to the provided url. + /// The response then is parsed to a list of ValueItem objects. + Future _fetchNetwork() async { + final result = await _performNetworkRequest(); + http.get(Uri.parse(widget.networkConfig!.url)); + if (result.statusCode == 200) { + final data = json.decode(result.body); + final List> parsedOptions = + await widget.responseParser!(data); + _reponseBody = null; + _options.addAll(parsedOptions); + } else { + _reponseBody = result.body; + } + } + + /// Perform the network request according to the provided configuration. + Future _performNetworkRequest() async { + switch (widget.networkConfig!.method) { + case RequestMethod.get: + return await http.get( + Uri.parse(widget.networkConfig!.url), + headers: widget.networkConfig!.headers, + ); + case RequestMethod.post: + return await http.post( + Uri.parse(widget.networkConfig!.url), + body: widget.networkConfig!.body, + headers: widget.networkConfig!.headers, + ); + case RequestMethod.put: + return await http.put( + Uri.parse(widget.networkConfig!.url), + body: widget.networkConfig!.body, + headers: widget.networkConfig!.headers, + ); + case RequestMethod.patch: + return await http.patch( + Uri.parse(widget.networkConfig!.url), + body: widget.networkConfig!.body, + headers: widget.networkConfig!.headers, + ); + case RequestMethod.delete: + return await http.delete( + Uri.parse(widget.networkConfig!.url), + headers: widget.networkConfig!.headers, + ); + default: + return await http.get( + Uri.parse(widget.networkConfig!.url), + headers: widget.networkConfig!.headers, + ); + } + } + + /// Builds overlay entry for showing error when fetching data from network fails. + OverlayEntry _buildNetworkErrorOverlayEntry() { + final values = _calculateOffsetSize(); + final size = values[0] as Size; + final showOnTop = values[1] as bool; + + // final offsetY = showOnTop ? -(size.height + 5) : size.height + 5; + + return OverlayEntry(builder: (context) { + return StatefulBuilder(builder: ((context, dropdownState) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: _onOutSideTap, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.transparent, + ), + )), + CompositedTransformFollower( + link: _layerLink, + targetAnchor: + showOnTop ? Alignment.topLeft : Alignment.bottomLeft, + followerAnchor: + showOnTop ? Alignment.bottomLeft : Alignment.topLeft, + offset: widget.dropdownMargin != null + ? Offset( + 0, + showOnTop + ? -widget.dropdownMargin! + : widget.dropdownMargin!) + : Offset.zero, + child: Material( + borderRadius: widget.dropdownBorderRadius != null + ? BorderRadius.circular(widget.dropdownBorderRadius!) + : null, + elevation: 4, + child: Container( + width: size.width, + constraints: BoxConstraints.loose( + Size(size.width, widget.dropdownHeight)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + widget.responseErrorBuilder != null + ? widget.responseErrorBuilder!( + context, _reponseBody) + : Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Error fetching data: $_reponseBody'), + ), + ], + )))) + ], + ); + })); + }); + } + + /// Clear the selected options. + /// [MultiSelectController] is used to clear the selected options. + void clear() { + if (!_controller._isDisposed) { + _controller.clearAllSelection(); + } else { + setState(() { + _selectedOptions.clear(); + }); + widget.onOptionSelected?.call(_selectedOptions); + } + if (_focusNode.hasFocus) _focusNode.unfocus(); + } + + /// handle the controller change. + void _handleControllerChange() { + // if the controller is null, return. + if (_controller.isDisposed == true) return; + + // if current disabled options are not equal to the controller's disabled options, update the state. + if (_disabledOptions != _controller.value._disabledOptions) { + setState(() { + _disabledOptions.clear(); + _disabledOptions.addAll(_controller.value._disabledOptions); + }); + } + + // if current options are not equal to the controller's options, update the state. + if (_options != _controller.value._options) { + setState(() { + _options.clear(); + _options.addAll(_controller.value._options); + }); + } + + // if current selected options are not equal to the controller's selected options, update the state. + if (_selectedOptions != _controller.value._selectedOptions) { + setState(() { + _selectedOptions.clear(); + _selectedOptions.addAll(_controller.value._selectedOptions); + }); + widget.onOptionSelected?.call(_selectedOptions); + } + + if (_selectionMode != _controller.value._isDropdownOpen) { + if (_controller.value._isDropdownOpen) { + _focusNode.requestFocus(); + } else { + _focusNode.unfocus(); + } + } + } + + // get the container padding. + EdgeInsetsGeometry _getContainerPadding() { + if (widget.padding != null) { + return widget.padding!; + } + return widget.selectionType == SelectionType.single + ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0) + : const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0); + } +} + +/// MultiSelect Controller class. +/// This class is used to control the state of the MultiSelectDropdown widget. +/// This is just base class. The implementation of this class is in the MultiSelectController class. +/// The implementation of this class is hidden from the user. +class _MultiSelectController { + final List> _disabledOptions = []; + final List> _options = []; + final List> _selectedOptions = []; + bool _isDropdownOpen = false; +} + +/// implementation of the MultiSelectController class. +class MultiSelectController + extends ValueNotifier<_MultiSelectController> { + MultiSelectController() : super(_MultiSelectController()); + + bool _isDisposed = false; + + bool get isDisposed => _isDisposed; + + /// set the dispose method. + @override + void dispose() { + super.dispose(); + _isDisposed = true; + } + + /// Clear the selected options. + /// [MultiSelectController] is used to clear the selected options. + void clearAllSelection() { + value._selectedOptions.clear(); + notifyListeners(); + } + + /// clear specific selected option + /// [MultiSelectController] is used to clear specific selected option. + void clearSelection(ValueItem option) { + if (!value._selectedOptions.contains(option)) return; + + if (value._disabledOptions.contains(option)) { + throw Exception('Cannot clear selection of a disabled option'); + } + + if (!value._options.contains(option)) { + throw Exception( + 'Cannot clear selection of an option that is not in the options list'); + } + + value._selectedOptions.remove(option); + notifyListeners(); + } + + /// select the options + /// [MultiSelectController] is used to select the options. + void setSelectedOptions(List> options) { + if (options.any((element) => value._disabledOptions.contains(element))) { + throw Exception('Cannot select disabled options'); + } + + if (options.any((element) => !value._options.contains(element))) { + throw Exception('Cannot select options that are not in the options list'); + } + + value._selectedOptions.clear(); + value._selectedOptions.addAll(options); + notifyListeners(); + } + + /// add selected option + /// [MultiSelectController] is used to add selected option. + void addSelectedOption(ValueItem option) { + if (value._disabledOptions.contains(option)) { + throw Exception('Cannot select disabled option'); + } + + if (!value._options.contains(option)) { + throw Exception('Cannot select option that is not in the options list'); + } + + value._selectedOptions.add(option); + notifyListeners(); + } + + /// set disabled options + /// [MultiSelectController] is used to set disabled options. + void setDisabledOptions(List> disabledOptions) { + if (disabledOptions.any((element) => !value._options.contains(element))) { + throw Exception( + 'Cannot disable options that are not in the options list'); + } + + value._disabledOptions.clear(); + value._disabledOptions.addAll(disabledOptions); + notifyListeners(); + } + + /// setDisabledOption method + /// [MultiSelectController] is used to set disabled option. + void setDisabledOption(ValueItem disabledOption) { + if (!value._options.contains(disabledOption)) { + throw Exception('Cannot disable option that is not in the options list'); + } + + value._disabledOptions.add(disabledOption); + notifyListeners(); + } + + /// set options + /// [MultiSelectController] is used to set options. + void setOptions(List> options) { + value._options.clear(); + value._options.addAll(options); + notifyListeners(); + } + + /// get disabled options + List> get disabledOptions => value._disabledOptions; + + /// get enabled options + List> get enabledOptions => value._options + .where((element) => !value._disabledOptions.contains(element)) + .toList(); + + /// get options + List> get options => value._options; + + /// get selected options + List> get selectedOptions => value._selectedOptions; + + /// get is dropdown open + bool get isDropdownOpen => value._isDropdownOpen; + + /// show dropdown + /// [MultiSelectController] is used to show dropdown. + void showDropdown() { + if (value._isDropdownOpen) return; + value._isDropdownOpen = true; + notifyListeners(); + } + + /// hide dropdown + /// [MultiSelectController] is used to hide dropdown. + void hideDropdown() { + if (!value._isDropdownOpen) return; + value._isDropdownOpen = false; + notifyListeners(); + } +} diff --git a/lib/widgets/common/multi-picker/widgets/hint_text.dart b/lib/widgets/common/multi-picker/widgets/hint_text.dart new file mode 100644 index 0000000..cf94465 --- /dev/null +++ b/lib/widgets/common/multi-picker/widgets/hint_text.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// [HintText] is a hint text builder. +/// It is used to build the hint text. +class HintText extends StatelessWidget { + final TextStyle? hintStyle; + final String hintText; + final Color? hintColor; + final EdgeInsetsGeometry? hintPadding; + + const HintText({ + Key? key, + this.hintStyle, + required this.hintText, + this.hintColor, + this.hintPadding, + }) : super(key: key); + + static const EdgeInsetsGeometry hintPaddingDefault = + EdgeInsets.symmetric(horizontal: 10.0); + + @override + Widget build(BuildContext context) { + return Padding( + padding: hintPadding ?? hintPaddingDefault, + child: Text( + hintText, + style: hintStyle ?? + TextStyle( + fontSize: 13, + color: hintColor ?? Colors.grey.shade300, + ), + ), + ); + } +} diff --git a/lib/widgets/common/multi-picker/widgets/selection_chip.dart b/lib/widgets/common/multi-picker/widgets/selection_chip.dart new file mode 100644 index 0000000..5c1cda0 --- /dev/null +++ b/lib/widgets/common/multi-picker/widgets/selection_chip.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import '../multiselect_dropdown.dart'; + +/// [SelectionChip] is a selected option chip builder. +/// It is used to build the selected option chip. +class SelectionChip extends StatelessWidget { + final ChipConfig chipConfig; + final Function(ValueItem) onItemDelete; + final ValueItem item; + + const SelectionChip({ + Key? key, + required this.chipConfig, + required this.item, + required this.onItemDelete, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Chip( + padding: chipConfig.padding, + label: Text(item.label), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(chipConfig.radius), + ), + deleteIcon: chipConfig.deleteIcon, + deleteIconColor: chipConfig.deleteIconColor, + labelPadding: chipConfig.labelPadding, + backgroundColor: + chipConfig.backgroundColor ?? Theme.of(context).primaryColor, + labelStyle: chipConfig.labelStyle ?? + TextStyle(color: chipConfig.labelColor, fontSize: 14), + onDeleted: () => onItemDelete(item), + ); + } +} diff --git a/lib/widgets/common/multi-picker/widgets/single_selected_item.dart b/lib/widgets/common/multi-picker/widgets/single_selected_item.dart new file mode 100644 index 0000000..e1f204d --- /dev/null +++ b/lib/widgets/common/multi-picker/widgets/single_selected_item.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// [SingleSelectedItem] is a selected item builder. +/// It is used to build the selected item. +class SingleSelectedItem extends StatelessWidget { + /// [label] is the selected item label. + final String label; + + final TextStyle? style; + + const SingleSelectedItem({ + required this.label, + this.style, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Text( + label, + style: style ?? + TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ); + } +} diff --git a/lib/widgets/core/sk_cascade_picker.dart b/lib/widgets/core/sk_cascade_picker.dart index 70d1880..ec4eaf5 100644 --- a/lib/widgets/core/sk_cascade_picker.dart +++ b/lib/widgets/core/sk_cascade_picker.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sk_base_mobile/app_theme.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; +import 'package:sk_base_mobile/widgets/core/sk_ink.dart'; import 'package:sk_base_mobile/widgets/gradient_button.dart'; import 'package:sk_base_mobile/widgets/loading_indicator.dart'; @@ -18,9 +19,9 @@ class SkCascadePicker extends StatelessWidget { final int maxPageNum; final Color tabColor; final double tabHeight; - final TextStyle tabTitleStyle; + final TextStyle? tabTitleStyle; final double itemHeight; - final TextStyle itemTitleStyle; + final TextStyle? itemTitleStyle; final Color itemColor; final Color activeColor; final Widget? selectedIcon; @@ -36,10 +37,10 @@ class SkCascadePicker extends StatelessWidget { this.tabHeight = 40, this.activeColor = AppTheme.primaryColor, this.tabColor = Colors.white, - this.tabTitleStyle = const TextStyle(color: Colors.black, fontSize: 14), + this.tabTitleStyle, this.itemHeight = 40, this.itemColor = Colors.white, - this.itemTitleStyle = const TextStyle(color: Colors.black, fontSize: 14), + this.itemTitleStyle, this.selectedIcon}) : cascadeController = Get.put(SkCascadePickerController( initialPageData: initialPageData, @@ -57,27 +58,65 @@ class SkCascadePicker extends StatelessWidget { common: true, ) : Column(children: [ - GradientButton( - buttonText: '确定', - onPressed: () { - Get.back(); - if (onConfirm != null) { - onConfirm!(cascadeController.selectedTabs - .where((e) => - e.label != SkCascadePickerController.newTabName) - .toList() as List>); - } - - // 已选中的titles - // List> selectedTitles = - // cascadeController.selectedTabs; - // print("已选中的titles: $selectedTitles"); - // 已选中的序号 - // List selectedIndexes = - // cascadeController.selectedIndexes; - // print("已选中的序号:$selectedIndexes"); - }, + Container( + padding: EdgeInsets.symmetric( + horizontal: ScreenAdaper.width(20), + vertical: ScreenAdaper.height(10)), + child: Row( + children: [ + SkInk( + onTap: () { + Get.back(); + }, + child: Text( + '取消', + style: TextStyle(fontSize: ScreenAdaper.height(30)), + )), + const Spacer(), + SkInk( + onTap: () { + Get.back(); + if (onConfirm != null) { + onConfirm!(cascadeController.selectedTabs + .where((e) => + e.label != + SkCascadePickerController.newTabName) + .toList() as List>); + } + }, + child: Text( + '确定', + style: TextStyle( + fontSize: ScreenAdaper.height(30), + color: AppTheme.primaryColor), + )) + ], + ), ), + const Divider( + height: 1, + ), + // GradientButton( + // buttonText: '确定', + // onPressed: () { + // Get.back(); + // if (onConfirm != null) { + // onConfirm!(cascadeController.selectedTabs + // .where((e) => + // e.label != SkCascadePickerController.newTabName) + // .toList() as List>); + // } + + // // 已选中的titles + // // List> selectedTitles = + // // cascadeController.selectedTabs; + // // print("已选中的titles: $selectedTitles"); + // // 已选中的序号 + // // List selectedIndexes = + // // cascadeController.selectedIndexes; + // // print("已选中的序号:$selectedIndexes"); + // }, + // ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -100,9 +139,6 @@ class SkCascadePicker extends StatelessWidget { ), ), ), - const Divider( - height: 1, - ), ValueListenableBuilder( valueListenable: cascadeController.sliderFixMargin, builder: (_, margin, __) => Positioned( @@ -181,8 +217,15 @@ class SkCascadePicker extends StatelessWidget { child: Text( cascadeController.selectedTabs[i].label, style: cascadeController.urrentSelectPage == i - ? tabTitleStyle.copyWith(color: activeColor) - : tabTitleStyle, + ? (tabTitleStyle ?? + TextStyle( + color: Colors.black, + fontSize: ScreenAdaper.height(30))) + .copyWith(color: activeColor) + : (tabTitleStyle ?? + TextStyle( + color: Colors.black, + fontSize: ScreenAdaper.height(30))), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -235,8 +278,15 @@ class SkCascadePicker extends StatelessWidget { : SizedBox(), Text(item.label, style: item == cascadeController.selectedTabs[page] - ? itemTitleStyle.copyWith(color: activeColor) - : itemTitleStyle), + ? (itemTitleStyle ?? + TextStyle( + color: Colors.black, + fontSize: ScreenAdaper.height(30))) + .copyWith(color: activeColor) + : (itemTitleStyle ?? + TextStyle( + color: Colors.black, + fontSize: ScreenAdaper.height(30)))), ], )), ), diff --git a/lib/widgets/core/sk_dialog_header.dart b/lib/widgets/core/sk_dialog_header.dart index 000786b..5613e9d 100644 --- a/lib/widgets/core/sk_dialog_header.dart +++ b/lib/widgets/core/sk_dialog_header.dart @@ -24,7 +24,7 @@ class SkDialogHeader extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - trailing ?? + leading ?? SizedBox( width: ScreenAdaper.width(100), ), diff --git a/lib/widgets/core/sk_tag.dart b/lib/widgets/core/sk_tag.dart index 52359cc..11f8bbc 100644 --- a/lib/widgets/core/sk_tag.dart +++ b/lib/widgets/core/sk_tag.dart @@ -24,7 +24,7 @@ class SkTag extends StatelessWidget { style: TextStyle( color: color ?? AppTheme.primaryColorLight, fontWeight: FontWeight.w600, - fontSize: ScreenAdaper.sp(30)), + fontSize: ScreenAdaper.height(25)), ), ); } diff --git a/lib/widgets/core/sk_base_field.dart b/lib/widgets/form_item/sk_base_field.dart similarity index 87% rename from lib/widgets/core/sk_base_field.dart rename to lib/widgets/form_item/sk_base_field.dart index 4f238f8..6ce7a67 100644 --- a/lib/widgets/core/sk_base_field.dart +++ b/lib/widgets/form_item/sk_base_field.dart @@ -4,15 +4,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; abstract class SkBaseFieldWidget extends StatelessWidget { - FocusNode focusNode = FocusNode(); - late SkBaseFieldController baseFieldController; + final FocusNode focusNode = FocusNode(); + late final SkBaseFieldController baseFieldController; final bool customLabel; final String? labelText; - + final Color? fillColor; SkBaseFieldWidget({ super.key, this.customLabel = false, this.labelText, + this.fillColor, autoFocus = false, }) { baseFieldController = Get.put( diff --git a/lib/widgets/core/sk_date_picker.dart b/lib/widgets/form_item/sk_date_picker.dart similarity index 100% rename from lib/widgets/core/sk_date_picker.dart rename to lib/widgets/form_item/sk_date_picker.dart diff --git a/lib/widgets/core/sk_form_item.dart b/lib/widgets/form_item/sk_form_item.dart similarity index 86% rename from lib/widgets/core/sk_form_item.dart rename to lib/widgets/form_item/sk_form_item.dart index dc217d1..dc8edb8 100644 --- a/lib/widgets/core/sk_form_item.dart +++ b/lib/widgets/form_item/sk_form_item.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart'; import 'package:sk_base_mobile/app_theme.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; -import 'package:sk_base_mobile/widgets/core/sk_base_field.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_base_field.dart'; class SkFormItem extends StatelessWidget { final Widget child; @@ -34,17 +34,21 @@ class SkFormItem extends StatelessWidget { style: TextStyle( color: Colors.red, fontSize: ScreenAdaper.height(25)), ), + SizedBox( + width: ScreenAdaper.width(5), + ), Obx(() => Text( labelText ?? '', style: TextStyle( fontSize: ScreenAdaper.height(25), + fontWeight: FontWeight.w600, color: controller.isFocus.value ? AppTheme.primaryColor : AppTheme.nearlyBlack), )), ]), SizedBox( - height: ScreenAdaper.height(5), + height: ScreenAdaper.height(8), ), child ], diff --git a/lib/widgets/core/sk_multi_picker.dart b/lib/widgets/form_item/sk_multi_picker.dart similarity index 66% rename from lib/widgets/core/sk_multi_picker.dart rename to lib/widgets/form_item/sk_multi_picker.dart index eea2f0b..97a1820 100644 --- a/lib/widgets/core/sk_multi_picker.dart +++ b/lib/widgets/form_item/sk_multi_picker.dart @@ -2,16 +2,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sk_base_mobile/app_theme.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; -import 'package:sk_base_mobile/widgets/core/sk_base_field.dart'; -import 'package:sk_base_mobile/widgets/core/sk_form_item.dart'; -import 'package:sk_base_mobile/widgets/core/sk_tag.dart'; -import 'package:multi_dropdown/multiselect_dropdown.dart'; +import 'package:sk_base_mobile/widgets/common/multi-picker/multiselect_dropdown.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_form_item.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_base_field.dart'; class SkMultiPickerDropdown extends SkBaseFieldWidget { - final TextEditingController textController; final Function(FocusNode)? onTap; final bool isRequired; - final String? labelText; final String? hint; final bool isTextArea; final bool isDense; @@ -19,29 +16,30 @@ class SkMultiPickerDropdown extends SkBaseFieldWidget { final Function(String)? onChanged; final String? Function(String?)? validator; final EdgeInsetsGeometry? contentPadding; - final ValueChanged? onFieldSubmitted; + final void Function(List>)? onOptionSelected; final Icon? prefix; final Widget? suffixIcon; final InputBorder? border; - final FloatingLabelBehavior? floatingLabelBehavior; - final TextInputType? keyboardType; + final List>? options; + final List>? selectedOptions; + final MultiSelectController multiSelectController = + MultiSelectController(); SkMultiPickerDropdown( {super.key, - required this.textController, super.customLabel = false, super.autoFocus = false, + super.labelText, + this.options, + this.selectedOptions, this.onTap, + this.onOptionSelected, this.hint, - this.onFieldSubmitted, this.isRequired = false, this.onTapOutside, - this.keyboardType, - this.labelText, this.prefix, this.suffixIcon, this.onChanged, this.border, - this.floatingLabelBehavior = FloatingLabelBehavior.always, this.isTextArea = false, this.contentPadding, this.isDense = false, @@ -50,35 +48,37 @@ class SkMultiPickerDropdown extends SkBaseFieldWidget { @override Widget build(BuildContext context) { return SkFormItem( - child: MultiSelectDropDown( - focusNode: focusNode, - onOptionSelected: (List selectedOptions) {}, - options: >[ - ValueItem(label: 'Option 1', value: 1), - ValueItem(label: 'Option 2', value: 2), - ValueItem(label: 'Option 3', value: 3), - ValueItem(label: 'Option 4', value: 4), - ValueItem(label: 'Option 5', value: 5), - ValueItem(label: 'Option 6', value: 6), - ], - borderColor: AppTheme.nearlyBlack, - fieldBackgroundColor: AppTheme.inputFillColor, - borderWidth: 1, - borderRadius: ScreenAdaper.sp(15), - focusedBorderWidth: 2, - focusedBorderColor: AppTheme.primaryColorLight, - hint: '请选择', - hintStyle: Theme.of(Get.context!).inputDecorationTheme.hintStyle, - hintPadding: EdgeInsets.symmetric(horizontal: ScreenAdaper.width(5)), - selectionType: SelectionType.multi, - chipConfig: const ChipConfig(wrapType: WrapType.scroll), - optionTextStyle: const TextStyle(fontSize: 16), - selectedOptionIcon: const Icon(Icons.check_circle), + customLabel: customLabel, + labelText: labelText, + isRequired: isRequired, + controller: baseFieldController, + child: MultiSelectDropDown( + controller: multiSelectController, + focusNode: focusNode, + selectedOptions: selectedOptions ?? [], + dropdownHeight: ScreenAdaper.height(300), + onOptionSelected: onOptionSelected, + options: options ?? >[], + borderColor: AppTheme.nearlyBlack, + fieldBackgroundColor: fillColor, + borderWidth: 1, + borderRadius: ScreenAdaper.sp(15), + focusedBorderWidth: 2, + focusedBorderColor: AppTheme.primaryColorLight, + hint: '请选择', + hintStyle: Theme.of(Get.context!).inputDecorationTheme.hintStyle, + hintPadding: EdgeInsets.symmetric(horizontal: ScreenAdaper.width(5)), + selectionType: SelectionType.multi, + chipConfig: ChipConfig( + wrapType: WrapType.scroll, + labelStyle: TextStyle( + fontSize: ScreenAdaper.height(26), color: AppTheme.white), + backgroundColor: AppTheme.primaryColor, ), - customLabel: customLabel, - labelText: labelText, - isRequired: isRequired, - controller: baseFieldController); + optionTextStyle: TextStyle(fontSize: ScreenAdaper.height(26)), + selectedOptionIcon: const Icon(Icons.check_circle), + ), + ); // return DropdownButtonFormField( // focusNode: focusNode, diff --git a/lib/widgets/core/sk_muti_search_more.dart b/lib/widgets/form_item/sk_multi_search_more.dart similarity index 100% rename from lib/widgets/core/sk_muti_search_more.dart rename to lib/widgets/form_item/sk_multi_search_more.dart diff --git a/lib/widgets/core/sk_number_input.dart b/lib/widgets/form_item/sk_number_input.dart similarity index 100% rename from lib/widgets/core/sk_number_input.dart rename to lib/widgets/form_item/sk_number_input.dart diff --git a/lib/widgets/core/sk_search_select.dart b/lib/widgets/form_item/sk_search_select.dart similarity index 100% rename from lib/widgets/core/sk_search_select.dart rename to lib/widgets/form_item/sk_search_select.dart diff --git a/lib/widgets/core/sk_single_search_more.dart b/lib/widgets/form_item/sk_single_search_more.dart similarity index 100% rename from lib/widgets/core/sk_single_search_more.dart rename to lib/widgets/form_item/sk_single_search_more.dart diff --git a/lib/widgets/core/sk_text_input.dart b/lib/widgets/form_item/sk_text_input.dart similarity index 92% rename from lib/widgets/core/sk_text_input.dart rename to lib/widgets/form_item/sk_text_input.dart index ede6bd6..f7fc27b 100644 --- a/lib/widgets/core/sk_text_input.dart +++ b/lib/widgets/form_item/sk_text_input.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:sk_base_mobile/app_theme.dart'; import 'package:sk_base_mobile/util/screen_adaper_util.dart'; -import 'package:sk_base_mobile/widgets/core/sk_base_field.dart'; -import 'package:sk_base_mobile/widgets/core/sk_form_item.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_form_item.dart'; +import 'package:sk_base_mobile/widgets/form_item/sk_base_field.dart'; class SkTextInput extends SkBaseFieldWidget { final TextEditingController textController; @@ -28,6 +29,7 @@ class SkTextInput extends SkBaseFieldWidget { super.customLabel = false, super.autoFocus = false, super.labelText, + super.fillColor, required this.textController, this.onTap, this.hint, @@ -75,6 +77,8 @@ class SkTextInput extends SkBaseFieldWidget { decoration: InputDecoration( prefixIcon: prefix, suffixIcon: suffixIcon, + fillColor: fillColor, + filled: true, errorStyle: const TextStyle(fontSize: 0, height: 0.01), contentPadding: contentPadding, isDense: isDense, diff --git a/pubspec.lock b/pubspec.lock index 8efef62..a180c01 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -608,14 +608,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - multi_dropdown: - dependency: "direct main" - description: - name: multi_dropdown - sha256: b63ff339fcc875d667f8688c8ef62853545b580dd2b6fe78b73339783268afd8 - url: "https://pub.dev" - source: hosted - version: "2.1.4" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 30c4af3..e6be3f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,7 +68,6 @@ dependencies: math_expressions: ^2.4.0 install_plugin: ^2.1.0 url_launcher: ^6.2.5 - multi_dropdown: ^2.1.4 dev_dependencies: flutter_test: sdk: flutter