// pubspec.yaml 需要添加依赖:lpinyin: ^2.0.3 import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:lpinyin/lpinyin.dart'; import 'package:qhd_prevention/customWidget/search_bar_widget.dart'; /// 用户数据模型 class Person { final String userId; final String name; final String departmentName; final String phone; final String postName; final Map? raw; Person({ required this.userId, required this.name, required this.departmentName, required this.phone, this.postName = '', this.raw, }); factory Person.fromJson(dynamic json) { if (json is Map) { return Person( userId: (json['id'] ?? json['actUser'] ?? '').toString(), name: (json['name'] ?? json['actUserName'] ??'').toString(), departmentName: (json['departmentName'] ?? json['actUserDepartmentName'] ?? '').toString(), phone: (json['phone'] ?? '').toString(), postName: (json['postName'] ?? '').toString(), raw: Map.from(json), ); } else { final s = json?.toString() ?? ''; return Person( userId: s, name: s, departmentName: '', phone: '', postName: '', raw: null, ); } } } /// 回调 typedef(保留原有) typedef PersonSelectCallback = void Function(String userId, String name); typedef PersonSelectCallbackWithIndex = void Function(String userId, String name, int index); typedef PersonSelectCallbackWithData = void Function(String userId, String name, Map data); typedef PersonSelectCallbackWithIndexAndData = void Function( String userId, String name, int index, Map data, ); typedef PersonMultiSelectCallback = void Function(List> selectedRawList); /// DepartmentAllPersonPicker class DepartmentAllPersonPicker { /// show 参数说明 /// - personsData:第一个 Tab(分公司) /// - serverData:第二个 Tab(相关方) /// - allowXgfFlag:false 时不显示 Tab,仅显示 personsData static Future show( BuildContext context, { required List personsData, List? serverData, bool allowXgfFlag = true, bool multiSelect = false, PersonMultiSelectCallback? onMultiSelected, PersonSelectCallback? onSelected, PersonSelectCallbackWithIndex? onSelectedWithIndex, PersonSelectCallbackWithData? onSelectedWithData, PersonSelectCallbackWithIndexAndData? onSelectedWithIndexWithData, }) async { assert( (!multiSelect && (onSelected != null || onSelectedWithIndex != null || onSelectedWithData != null || onSelectedWithIndexWithData != null)) || (multiSelect && (onMultiSelected != null || onSelectedWithData != null || onSelectedWithIndexWithData != null || onSelectedWithIndex != null || onSelected != null)), '请至少传入一个回调(单选或多选)', ); await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => _DepartmentAllPersonPickerSheet( personsData: personsData, serverData: serverData, allowXgfFlag: allowXgfFlag, multiSelect: multiSelect, onMultiSelected: onMultiSelected, onSelected: onSelected, onSelectedWithIndex: onSelectedWithIndex, onSelectedWithData: onSelectedWithData, onSelectedWithIndexWithData: onSelectedWithIndexWithData, ), ); } } class _DepartmentAllPersonPickerSheet extends StatefulWidget { final List personsData; final List? serverData; final bool allowXgfFlag; final bool multiSelect; final PersonMultiSelectCallback? onMultiSelected; final PersonSelectCallback? onSelected; final PersonSelectCallbackWithIndex? onSelectedWithIndex; final PersonSelectCallbackWithData? onSelectedWithData; final PersonSelectCallbackWithIndexAndData? onSelectedWithIndexWithData; const _DepartmentAllPersonPickerSheet({ required this.personsData, required this.serverData, required this.allowXgfFlag, required this.multiSelect, required this.onMultiSelected, required this.onSelected, required this.onSelectedWithIndex, required this.onSelectedWithData, required this.onSelectedWithIndexWithData, }); @override State<_DepartmentAllPersonPickerSheet> createState() => _DepartmentAllPersonPickerSheetState(); } class _DepartmentAllPersonPickerSheetState extends State<_DepartmentAllPersonPickerSheet> with SingleTickerProviderStateMixin { late final List _personAll; late final List _serverAll; List _personFiltered = []; List _serverFiltered = []; final TextEditingController _searchController = TextEditingController(); final ScrollController _personController = ScrollController(); final ScrollController _serverController = ScrollController(); TabController? _tabController; final Map _currentLetters = {0: '', 1: ''}; String _singleSelectedKey = ''; String _singleSelectedId = ''; String _singleSelectedName = ''; Person? _singleSelectedPerson; int _singleSelectedSourceIndex = -1; String _singleSelectedSource = ''; final Set _multiSelectedKeys = {}; static const double _headerHeight = 40.0; static const double _itemHeight = 50.0; static const double _rightReservedWidth = 30.0; static const double _trailingWidth = 56.0; bool get _showTabs => widget.allowXgfFlag; @override void initState() { super.initState(); // personsData去重 final Map uniqueMap = {}; for (final item in widget.personsData) { if (item is Map) { final key = item['id'] ?? item['actUser']; // id去重 uniqueMap[key] = item; } } final List list = uniqueMap.values.toList(); _personAll = list.map((e) => Person.fromJson(e)).toList(); _serverAll = (widget.serverData ?? []).map((e) => Person.fromJson(e)).toList(); _personFiltered = List.from(_personAll); _serverFiltered = List.from(_serverAll); if (_showTabs) { _tabController = TabController(length: 2, vsync: this); _tabController!.addListener(_onTabChanged); } _searchController.addListener(_onSearchChanged); _personController.addListener(() => _updateCurrentLetter(0)); _serverController.addListener(() => _updateCurrentLetter(1)); } @override void dispose() { _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _personController.dispose(); _serverController.dispose(); _tabController?.removeListener(_onTabChanged); _tabController?.dispose(); super.dispose(); } void _onTabChanged() { setState(() {}); } void _onSearchChanged() { final q = _searchController.text.toLowerCase().trim(); setState(() { _personFiltered = q.isEmpty ? List.from(_personAll) : _personAll.where((p) { final nameLower = p.name.toLowerCase(); final phoneLower = p.phone.toLowerCase(); return nameLower.contains(q) || phoneLower.contains(q); }).toList(); _serverFiltered = q.isEmpty ? List.from(_serverAll) : _serverAll.where((p) { final nameLower = p.name.toLowerCase(); final phoneLower = p.phone.toLowerCase(); return nameLower.contains(q) || phoneLower.contains(q); }).toList(); }); _updateCurrentLetter(0); _updateCurrentLetter(1); } String _itemKey(String source, String id) => '$source::$id'; String _getInitial(String name) { final trimmed = name.trim(); if (trimmed.isEmpty) return '#'; final first = trimmed[0]; if (RegExp(r'[A-Za-z]').hasMatch(first)) return first.toUpperCase(); if (RegExp(r'[\u4e00-\u9fff]').hasMatch(first)) { try { final short = PinyinHelper.getShortPinyin(trimmed); if (short.isNotEmpty) return short[0].toUpperCase(); } catch (_) {} } return '#'; } bool _detectIsRelated(dynamic raw) { try { if (raw is Map) { if (raw.containsKey('isXgf') || raw.containsKey('isxgf')) { final v = raw['isXgf'] ?? raw['isxgf']; if (v is bool) return v; if (v is num) return v != 0; if (v is String) return v == '1' || v.toLowerCase() == 'true'; } if (raw.containsKey('isRelated') || raw.containsKey('isrelated') || raw.containsKey('related')) { final v = raw['isRelated'] ?? raw['isrelated'] ?? raw['related']; if (v is bool) return v; if (v is num) return v != 0; if (v is String) return v == '1' || v.toLowerCase() == 'true'; } final dept = (raw['departmentName'] ?? raw['deptName'] ?? raw['department'] ?? '').toString(); if (dept.contains('相关')) return true; if (raw.containsKey('allowXgfFlag')) { final v = raw['allowXgfFlag']; if (v is bool) return !v; if (v is num) return v == 0; if (v is String) return v == '0' || v.toLowerCase() == 'false'; } } } catch (_) {} return false; } bool _canSelectByAllowFlag(dynamic raw) { if (widget.allowXgfFlag) return true; return !_detectIsRelated(raw); } Map> _buildGroupMap(List source) { final Map> map = {}; for (final p in source) { final key = _getInitial(p.name); map.putIfAbsent(key, () => []).add(p); } for (final k in map.keys) { map[k]!.sort((a, b) => a.name.compareTo(b.name)); } return map; } List _orderedKeys(Map> groupMap) { final alphaKeys = groupMap.keys .where((k) => RegExp(r'^[A-Z]$').hasMatch(k)) .toList() ..sort(); final otherKeys = groupMap.keys .where((k) => !RegExp(r'^[A-Z]$').hasMatch(k)) .toList() ..sort(); return [...alphaKeys, ...otherKeys]; } Map _computeOffsets(List keys, Map> map) { final offsets = {}; double cursor = 0.0; for (final k in keys) { offsets[k] = cursor; cursor += _headerHeight + map[k]!.length * _itemHeight; } return offsets; } void _updateCurrentLetter(int tabIndex) { final source = tabIndex == 0 ? _personFiltered : _serverFiltered; final controller = tabIndex == 0 ? _personController : _serverController; if (!controller.hasClients) return; if (source.isEmpty) { if (_currentLetters[tabIndex] != '') { setState(() => _currentLetters[tabIndex] = ''); } return; } final groupMap = _buildGroupMap(source); final keys = _orderedKeys(groupMap); if (keys.isEmpty) return; final offsets = _computeOffsets(keys, groupMap); final pos = controller.position.pixels; String found = keys.first; for (final k in keys) { final off = offsets[k] ?? double.infinity; if (pos >= off) { found = k; } else { break; } } if (_currentLetters[tabIndex] != found) { setState(() { _currentLetters[tabIndex] = found; }); } } Future _scrollToLetter( String letter, { required int tabIndex, required List keys, required Map offsets, }) async { final controller = tabIndex == 0 ? _personController : _serverController; final targetOffset = offsets[letter] ?? 0.0; if (!controller.hasClients) return; try { await controller.animateTo( targetOffset, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, ); } catch (_) {} setState(() { _currentLetters[tabIndex] = letter; }); } String _buildTitleText(Person person) { if (person.postName.isEmpty && person.phone.isEmpty && person.departmentName.isEmpty) return ''; final postPart = person.postName.isNotEmpty ? '/${person.postName}' : ''; return '${person.name}-${person.phone}(${person.departmentName}$postPart)'; } void _onItemTap({ required Person item, required int sourceIndex, required String source, }) { final raw = item.raw; if (!_canSelectByAllowFlag(raw)) return; final key = _itemKey(source, item.userId); setState(() { if (widget.multiSelect) { if (_multiSelectedKeys.contains(key)) { _multiSelectedKeys.remove(key); } else { _multiSelectedKeys.add(key); } } else { _singleSelectedKey = key; _singleSelectedId = item.userId; _singleSelectedName = item.name; _singleSelectedPerson = item; _singleSelectedSourceIndex = sourceIndex; _singleSelectedSource = source; } }); } bool _confirmEnabled() { if (widget.multiSelect) { return _multiSelectedKeys.isNotEmpty; } return _singleSelectedKey.isNotEmpty; } Map _toDataMap(Person person) { return person.raw ?? { 'id': person.userId, 'name': person.name, 'departmentName': person.departmentName, 'phone': person.phone, 'postName': person.postName, }; } Person? _findPersonByKey(String key) { final parts = key.split('::'); if (parts.length != 2) return null; final source = parts[0]; final id = parts[1]; if (source == 'person') { for (final p in _personAll) { if (p.userId == id) return p; } } else if (source == 'server') { for (final p in _serverAll) { if (p.userId == id) return p; } } return null; } Widget _buildGroupedList({ required List source, required ScrollController controller, required int tabIndex, required String emptyText, }) { if (source.isEmpty) { return Center( child: Text( emptyText, style: const TextStyle(fontSize: 14, color: Colors.grey), ), ); } final groupMap = _buildGroupMap(source); final keys = _orderedKeys(groupMap); final offsets = _computeOffsets(keys, groupMap); final currentLetter = _currentLetters[tabIndex] ?? ''; return Stack( children: [ ListView( controller: controller, padding: EdgeInsets.zero, children: keys.expand((k) { final items = groupMap[k]!; return [ Container( height: _headerHeight, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: Colors.grey[200], alignment: Alignment.centerLeft, child: Text( k, style: const TextStyle(fontWeight: FontWeight.bold), ), ), ...items.asMap().entries.map((entry) { final idx = entry.key; final item = entry.value; final sourceIndex = source.indexWhere((p) => p.userId == item.userId); final raw = item.raw; final isRelated = !_canSelectByAllowFlag(raw); final selectedKey = _itemKey(tabIndex == 0 ? 'person' : 'server', item.userId); final selected = widget.multiSelect ? _multiSelectedKeys.contains(selectedKey) : (_singleSelectedKey == selectedKey); return InkWell( onTap: isRelated ? null : () => _onItemTap( item: item, sourceIndex: sourceIndex >= 0 ? sourceIndex : idx, source: tabIndex == 0 ? 'person' : 'server', ), child: Container( height: _itemHeight, padding: const EdgeInsets.only(left: 16, right: _rightReservedWidth), alignment: Alignment.centerLeft, color: isRelated ? Colors.grey[50] : Colors.white, child: Row( children: [ Expanded( child: Text( _buildTitleText(item), style: TextStyle( color: isRelated ? Colors.grey : Colors.black, ), ), ), SizedBox( width: _trailingWidth, child: selected ? const Align( alignment: Alignment.centerRight, child: Icon(Icons.check, color: Colors.blue), ) : const SizedBox.shrink(), ), ], ), ), ); }), ]; }).toList(), ), if (keys.isNotEmpty) Positioned( right: 4, top: 100, bottom: 100, child: Column( mainAxisSize: MainAxisSize.min, children: keys.map((letter) { final isActive = letter == currentLetter; return GestureDetector( onTap: () => _scrollToLetter( letter, tabIndex: tabIndex, keys: keys, offsets: offsets, ), child: Container( margin: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), child: Text( letter, style: TextStyle( fontSize: 12, color: isActive ? Colors.blue : Colors.black54, ), ), ), ); }).toList(), ), ), ], ); } void _confirm() { if (!mounted) return; if (widget.multiSelect) { final selectedRaw = _multiSelectedKeys.map((key) { final person = _findPersonByKey(key); if (person != null) { return _toDataMap(person); } final parts = key.split('::'); final id = parts.length == 2 ? parts[1] : key; return { 'id': id, 'username': '', }; }).toList(); if (widget.onMultiSelected != null) { widget.onMultiSelected!(selectedRaw); return; } if (_multiSelectedKeys.isEmpty) return; final firstKey = _multiSelectedKeys.first; final firstPerson = _findPersonByKey(firstKey); final firstId = firstPerson?.userId ?? ''; final firstName = firstPerson?.name ?? ''; if (firstPerson != null) { final idx = firstKey.startsWith('person::') ? _personAll.indexWhere((p) => p.userId == firstId) : _serverAll.indexWhere((p) => p.userId == firstId); final dataMap = _toDataMap(firstPerson); if (widget.onSelectedWithIndexWithData != null) { widget.onSelectedWithIndexWithData!(firstId, firstName, idx, dataMap); return; } if (widget.onSelectedWithData != null) { widget.onSelectedWithData!(firstId, firstName, dataMap); return; } if (widget.onSelectedWithIndex != null) { widget.onSelectedWithIndex!(firstId, firstName, idx); return; } if (widget.onSelected != null) { widget.onSelected!(firstId, firstName); return; } } return; } final selectedPerson = _singleSelectedPerson; if (selectedPerson == null) return; final dataMap = _toDataMap(selectedPerson); if (widget.onSelectedWithIndexWithData != null) { widget.onSelectedWithIndexWithData!( selectedPerson.userId, selectedPerson.name, _singleSelectedSourceIndex, dataMap, ); return; } if (widget.onSelectedWithData != null) { widget.onSelectedWithData!( selectedPerson.userId, selectedPerson.name, dataMap, ); return; } if (widget.onSelectedWithIndex != null) { widget.onSelectedWithIndex!( selectedPerson.userId, selectedPerson.name, _singleSelectedSourceIndex, ); return; } if (widget.onSelected != null) { widget.onSelected!(selectedPerson.userId, selectedPerson.name); return; } } @override Widget build(BuildContext context) { final showTabs = _showTabs; final tabIndex = _tabController?.index ?? 0; final personGroupMap = _buildGroupMap(_personFiltered); final serverGroupMap = _buildGroupMap(_serverFiltered); final personKeys = _orderedKeys(personGroupMap); final serverKeys = _orderedKeys(serverGroupMap); final personOffsets = _computeOffsets(personKeys, personGroupMap); final serverOffsets = _computeOffsets(serverKeys, serverGroupMap); final personListWidget = _buildGroupedList( source: _personFiltered, controller: _personController, tabIndex: 0, emptyText: '暂无数据', ); final serverListWidget = _buildGroupedList( source: _serverFiltered, controller: _serverController, tabIndex: 1, emptyText: '暂无数据', ); Widget contentWidget; if (!showTabs) { contentWidget = personListWidget; } else { contentWidget = TabBarView( controller: _tabController, children: [ personListWidget, serverListWidget, ], ); } return SafeArea( child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height * 0.82, decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('取消', style: TextStyle(fontSize: 16)), ), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: SearchBarWidget( controller: _searchController, isShowSearchButton: false, onSearch: (keyboard) { _onSearchChanged(); }, ), ), ), TextButton( onPressed: _confirmEnabled() ? () { Navigator.of(context).pop(); _confirm(); } : null, child: Text( '确定', style: TextStyle( color: _confirmEnabled() ? Colors.blue : Colors.grey, fontSize: 16, ), ), ), ], ), ), const Divider(height: 1), if (showTabs) ...[ TabBar( controller: _tabController, tabs: const [ Tab(text: '分公司'), Tab(text: '相关方'), ], indicatorColor: Colors.blue, labelColor: Colors.blue, unselectedLabelColor: Colors.black54, ), const Divider(height: 1), ], Expanded(child: contentWidget), ], ), ), ); } }