466 lines
13 KiB
Dart
466 lines
13 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/search_bar_widget.dart';
|
|||
|
|
import 'package:qhd_prevention/http/ApiService.dart';
|
|||
|
|
import 'package:qhd_prevention/pages/mine/onboarding_full_page.dart';
|
|||
|
|
import 'package:qhd_prevention/pages/my_appbar.dart';
|
|||
|
|
|
|||
|
|
// lpinyin 用于中文转拼音
|
|||
|
|
import 'package:lpinyin/lpinyin.dart';
|
|||
|
|
import 'package:qhd_prevention/tools/tools.dart';
|
|||
|
|
|
|||
|
|
class FirmListPage extends StatefulWidget {
|
|||
|
|
const FirmListPage({super.key});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
State<FirmListPage> createState() => _FirmListPageState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _FirmListPageState extends State<FirmListPage> {
|
|||
|
|
List _firmList = []; // 原始数据(全部)
|
|||
|
|
List _displayList = []; // 经过搜索过滤后用于显示的数据
|
|||
|
|
|
|||
|
|
final Map<String, List<Map>> _sections = {};
|
|||
|
|
final List<String> _sectionOrder = [];
|
|||
|
|
|
|||
|
|
final ScrollController _scrollController = ScrollController();
|
|||
|
|
final TextEditingController _searchController = TextEditingController();
|
|||
|
|
|
|||
|
|
static const double headerHeight = 40.0;
|
|||
|
|
static const double itemHeight = 64.0;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
_getFirmList();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _getFirmList() async {
|
|||
|
|
try {
|
|||
|
|
LoadingDialogHelper.show();
|
|||
|
|
final result = await BasicInfoApi.getFirmList({'enterpriseType': 3});
|
|||
|
|
LoadingDialogHelper.hide();
|
|||
|
|
if (result['success'] == true && result['data'] is List) {
|
|||
|
|
setState(() {
|
|||
|
|
_firmList = List.from(result['data']);
|
|||
|
|
_displayList = List.from(_firmList);
|
|||
|
|
});
|
|||
|
|
_groupAndSort();
|
|||
|
|
} else {
|
|||
|
|
setState(() {
|
|||
|
|
_firmList = [];
|
|||
|
|
_displayList = [];
|
|||
|
|
_sections.clear();
|
|||
|
|
_sectionOrder.clear();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// 处理异常
|
|||
|
|
setState(() {
|
|||
|
|
_firmList = [];
|
|||
|
|
_displayList = [];
|
|||
|
|
_sections.clear();
|
|||
|
|
_sectionOrder.clear();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 取企业显示名(优先字段)
|
|||
|
|
String _firmName(Map m) {
|
|||
|
|
final keys = [
|
|||
|
|
'corpName',
|
|||
|
|
'companyName',
|
|||
|
|
'name',
|
|||
|
|
'firmName',
|
|||
|
|
'title',
|
|||
|
|
'epcProjectName',
|
|||
|
|
];
|
|||
|
|
for (final k in keys) {
|
|||
|
|
if (m.containsKey(k) &&
|
|||
|
|
m[k] != null &&
|
|||
|
|
m[k].toString().trim().isNotEmpty) {
|
|||
|
|
return m[k].toString().trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 若没有合适字段,尝试取第一个非空 value
|
|||
|
|
for (final entry in m.entries) {
|
|||
|
|
final v = entry.value;
|
|||
|
|
if (v is String && v.trim().isNotEmpty) return v.trim();
|
|||
|
|
if (v is num) return v.toString();
|
|||
|
|
}
|
|||
|
|
return '未命名企业';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 将 displayList 按拼音首字母分组并排序
|
|||
|
|
void _groupAndSort() {
|
|||
|
|
_sections.clear();
|
|||
|
|
|
|||
|
|
for (final raw in _displayList) {
|
|||
|
|
if (raw is! Map) continue;
|
|||
|
|
final name = _firmName(raw);
|
|||
|
|
|
|||
|
|
String shortPinyin = '';
|
|||
|
|
try {
|
|||
|
|
shortPinyin = PinyinHelper.getShortPinyin(name);
|
|||
|
|
} catch (_) {
|
|||
|
|
shortPinyin = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final firstLetter =
|
|||
|
|
(shortPinyin.isNotEmpty
|
|||
|
|
? shortPinyin[0].toUpperCase()
|
|||
|
|
: (name.isNotEmpty ? name[0].toUpperCase() : '#'));
|
|||
|
|
final letter =
|
|||
|
|
RegExp(r'^[A-Z]$').hasMatch(firstLetter) ? firstLetter : '#';
|
|||
|
|
|
|||
|
|
_sections.putIfAbsent(letter, () => []).add(raw);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 对每个分组内部按拼音全拼排序(不带声调)
|
|||
|
|
for (final k in _sections.keys) {
|
|||
|
|
_sections[k]!.sort((a, b) {
|
|||
|
|
final na = _firmName(a);
|
|||
|
|
final nb = _firmName(b);
|
|||
|
|
|
|||
|
|
String pa;
|
|||
|
|
String pb;
|
|||
|
|
try {
|
|||
|
|
pa =
|
|||
|
|
PinyinHelper.getPinyin(
|
|||
|
|
na,
|
|||
|
|
separator: '',
|
|||
|
|
format: PinyinFormat.WITHOUT_TONE,
|
|||
|
|
).toLowerCase();
|
|||
|
|
} catch (_) {
|
|||
|
|
pa = na.toLowerCase();
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
pb =
|
|||
|
|
PinyinHelper.getPinyin(
|
|||
|
|
nb,
|
|||
|
|
separator: '',
|
|||
|
|
format: PinyinFormat.WITHOUT_TONE,
|
|||
|
|
).toLowerCase();
|
|||
|
|
} catch (_) {
|
|||
|
|
pb = nb.toLowerCase();
|
|||
|
|
}
|
|||
|
|
return pa.compareTo(pb);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 构建分组顺序:A..Z,然后 '#'
|
|||
|
|
final letters = List<String>.generate(
|
|||
|
|
26,
|
|||
|
|
(i) => String.fromCharCode(65 + i),
|
|||
|
|
);
|
|||
|
|
final order = <String>[];
|
|||
|
|
for (final l in letters) {
|
|||
|
|
if (_sections.containsKey(l)) order.add(l);
|
|||
|
|
}
|
|||
|
|
if (_sections.containsKey('#')) order.add('#');
|
|||
|
|
|
|||
|
|
setState(() {
|
|||
|
|
_sectionOrder
|
|||
|
|
..clear()
|
|||
|
|
..addAll(order);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 过滤函数:支持中文直接匹配,也支持拼音匹配
|
|||
|
|
void _applyFilter(String q) {
|
|||
|
|
final query = q.trim();
|
|||
|
|
if (query.isEmpty) {
|
|||
|
|
setState(() {
|
|||
|
|
_displayList = List.from(_firmList);
|
|||
|
|
});
|
|||
|
|
_groupAndSort();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final qLower = query.toLowerCase();
|
|||
|
|
final filtered = <dynamic>[];
|
|||
|
|
|
|||
|
|
for (final raw in _firmList) {
|
|||
|
|
if (raw is! Map) continue;
|
|||
|
|
final name = _firmName(raw);
|
|||
|
|
final nameLower = name.toLowerCase();
|
|||
|
|
|
|||
|
|
bool matched = false;
|
|||
|
|
// 1) 中文/英文直接包含
|
|||
|
|
if (nameLower.contains(qLower)) matched = true;
|
|||
|
|
|
|||
|
|
// 2) 拼音匹配
|
|||
|
|
if (!matched) {
|
|||
|
|
try {
|
|||
|
|
final pinyin =
|
|||
|
|
PinyinHelper.getPinyin(
|
|||
|
|
name,
|
|||
|
|
separator: '',
|
|||
|
|
format: PinyinFormat.WITHOUT_TONE,
|
|||
|
|
).toLowerCase();
|
|||
|
|
if (pinyin.contains(qLower)) matched = true;
|
|||
|
|
} catch (_) {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (matched) filtered.add(raw);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setState(() {
|
|||
|
|
_displayList = filtered;
|
|||
|
|
});
|
|||
|
|
_groupAndSort();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算某个分组的列表起始偏移(基于 headerHeight/itemHeight 的估算)
|
|||
|
|
double _offsetForSection(String letter) {
|
|||
|
|
double offset = 0.0;
|
|||
|
|
for (final l in _sectionOrder) {
|
|||
|
|
if (l == letter) break;
|
|||
|
|
offset += headerHeight;
|
|||
|
|
final cnt = _sections[l]?.length ?? 0;
|
|||
|
|
offset += cnt * itemHeight;
|
|||
|
|
}
|
|||
|
|
return offset;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _jumpToLetter(String letter) {
|
|||
|
|
if (_sectionOrder.isEmpty) return;
|
|||
|
|
|
|||
|
|
if (_sections.containsKey(letter)) {
|
|||
|
|
final off = _offsetForSection(letter);
|
|||
|
|
_scrollController.animateTo(
|
|||
|
|
off,
|
|||
|
|
duration: const Duration(milliseconds: 250),
|
|||
|
|
curve: Curves.easeInOut,
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final all =
|
|||
|
|
List<String>.generate(26, (i) => String.fromCharCode(65 + i)) + ['#'];
|
|||
|
|
final idx = all.indexOf(letter);
|
|||
|
|
if (idx == -1) return;
|
|||
|
|
for (int i = idx + 1; i < all.length; i++) {
|
|||
|
|
final l = all[i];
|
|||
|
|
if (_sections.containsKey(l)) {
|
|||
|
|
final off = _offsetForSection(l);
|
|||
|
|
_scrollController.animateTo(
|
|||
|
|
off,
|
|||
|
|
duration: const Duration(milliseconds: 250),
|
|||
|
|
curve: Curves.easeInOut,
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final last = _sectionOrder.last;
|
|||
|
|
final off = _offsetForSection(last);
|
|||
|
|
_scrollController.animateTo(
|
|||
|
|
off,
|
|||
|
|
duration: const Duration(milliseconds: 250),
|
|||
|
|
curve: Curves.easeInOut,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
List<String> get _azIndex {
|
|||
|
|
final list = List<String>.generate(26, (i) => String.fromCharCode(65 + i));
|
|||
|
|
list.add('#');
|
|||
|
|
return list;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
// 右侧字母索引(灰色圆角背景),使用固定高度以忽略键盘导致的可用高度变化
|
|||
|
|
final mq = MediaQuery.of(context);
|
|||
|
|
final fixedIndexHeight =
|
|||
|
|
mq.size.height - kToolbarHeight - mq.padding.top - 24-100;
|
|||
|
|
return Scaffold(
|
|||
|
|
backgroundColor: Colors.white,
|
|||
|
|
|
|||
|
|
appBar: MyAppbar(title: '选择企业'),
|
|||
|
|
body: SafeArea(
|
|||
|
|
child: Column(
|
|||
|
|
children: [
|
|||
|
|
// 搜索框(固定在顶部)
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|||
|
|
child: SearchBarWidget(
|
|||
|
|
onSearch: (keyboard) {},
|
|||
|
|
controller: _searchController,
|
|||
|
|
isShowSearchButton: false,
|
|||
|
|
resetButtonText: '重置',
|
|||
|
|
showResetButton: true,
|
|||
|
|
onTextChanged: (text) {
|
|||
|
|
_applyFilter(text);
|
|||
|
|
},
|
|||
|
|
onReset: () {
|
|||
|
|
_searchController.clear();
|
|||
|
|
_applyFilter('');
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 列表区域
|
|||
|
|
Expanded(
|
|||
|
|
child: Stack(
|
|||
|
|
children: [
|
|||
|
|
// 列表区域:RefreshIndicator 必须包裹可滚动控件(ListView)
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.only(right: 48.0), // 给右侧字母索引留空间
|
|||
|
|
child: _buildListView(),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 24 是上/下 margin 的大致预留(12 + 12),根据你视觉需要可调
|
|||
|
|
Positioned(
|
|||
|
|
right: 8,
|
|||
|
|
top: 12,
|
|||
|
|
// 不使用 bottom(避免随键盘收缩),改为固定高度(独立于 viewInsets)
|
|||
|
|
height: fixedIndexHeight,
|
|||
|
|
child: _buildAlphabetIndex(fixedIndexHeight),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildListView() {
|
|||
|
|
if (_sectionOrder.isEmpty) {
|
|||
|
|
return ListView(
|
|||
|
|
controller: _scrollController,
|
|||
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|||
|
|
children: [
|
|||
|
|
const SizedBox(height: 30),
|
|||
|
|
Center(
|
|||
|
|
child: Text(
|
|||
|
|
_displayList.isEmpty ? ' 暂无企业' : ' 正在加载…',
|
|||
|
|
style: TextStyle(fontSize: 15, color: Colors.grey[600]),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final children = <Widget>[];
|
|||
|
|
for (final letter in _sectionOrder) {
|
|||
|
|
children.add(_buildSectionHeader(letter));
|
|||
|
|
final items = _sections[letter] ?? [];
|
|||
|
|
for (final item in items) {
|
|||
|
|
children.add(_buildItem(item));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ListView(
|
|||
|
|
controller: _scrollController,
|
|||
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|||
|
|
padding: const EdgeInsets.symmetric(vertical: 0),
|
|||
|
|
children: children,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildSectionHeader(String letter) {
|
|||
|
|
return Container(
|
|||
|
|
height: headerHeight,
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
|||
|
|
color: Colors.grey.shade100,
|
|||
|
|
alignment: Alignment.centerLeft,
|
|||
|
|
child: Text(
|
|||
|
|
letter,
|
|||
|
|
style: const TextStyle(
|
|||
|
|
fontSize: 14,
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
color: Colors.black87,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildItem(Map firm) {
|
|||
|
|
final name = _firmName(firm);
|
|||
|
|
return InkWell(
|
|||
|
|
onTap: () {
|
|||
|
|
pushPage(OnboardingFullPage(scanData: firm), context);
|
|||
|
|
},
|
|||
|
|
child: Container(
|
|||
|
|
height: itemHeight,
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
|||
|
|
color: Colors.white,
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
Expanded(
|
|||
|
|
child: Text(
|
|||
|
|
name,
|
|||
|
|
style: const TextStyle(fontSize: 15),
|
|||
|
|
maxLines: 1,
|
|||
|
|
overflow: TextOverflow.ellipsis,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildAlphabetIndex(double height) {
|
|||
|
|
final letters = _azIndex;
|
|||
|
|
return SizedBox(
|
|||
|
|
width: 20,
|
|||
|
|
height: height,
|
|||
|
|
child: Container(
|
|||
|
|
// 背景圆角盒子占满高度
|
|||
|
|
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 4),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: Colors.grey.shade200,
|
|||
|
|
borderRadius: BorderRadius.circular(10),
|
|||
|
|
boxShadow: const [
|
|||
|
|
BoxShadow(
|
|||
|
|
color: Colors.black12,
|
|||
|
|
blurRadius: 4,
|
|||
|
|
offset: Offset(0, 2),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
child: Column(
|
|||
|
|
// 等间隔排列字母,使它们在固定高度内均匀分布,不会被键盘压缩
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|||
|
|
children:
|
|||
|
|
letters.map((l) {
|
|||
|
|
final enabled = _sections.containsKey(l);
|
|||
|
|
return GestureDetector(
|
|||
|
|
behavior: HitTestBehavior.opaque,
|
|||
|
|
onTap: () => _jumpToLetter(l),
|
|||
|
|
child: Container(
|
|||
|
|
// 尽量让每个字母区域可点击,并根据启用状态改变样式
|
|||
|
|
padding: const EdgeInsets.symmetric(
|
|||
|
|
vertical: 2,
|
|||
|
|
horizontal: 4,
|
|||
|
|
),
|
|||
|
|
alignment: Alignment.center,
|
|||
|
|
child: Text(
|
|||
|
|
l,
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: 11,
|
|||
|
|
color: enabled ? Colors.blue : Colors.grey.shade400,
|
|||
|
|
fontWeight: FontWeight.w500,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}).toList(),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
_scrollController.dispose();
|
|||
|
|
_searchController.dispose();
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
}
|