expense_apply_import_page.dart 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:easy_refresh/easy_refresh.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../core/utils/amount_utils.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../shared/widgets/date_range_picker.dart';
  9. import '../../shared/widgets/empty_state.dart';
  10. import '../../shared/widgets/app_skeletons.dart';
  11. import '../../shared/widgets/list_footer.dart';
  12. import 'expense_api.dart';
  13. /// 可导入的费用申请明细项
  14. class ImportableItem {
  15. final String aeNo;
  16. final String aeDd;
  17. final String reason;
  18. final double headAmtnYj;
  19. final int itm;
  20. final int? preItm;
  21. final String sqMan;
  22. final String sqName;
  23. final String typeNo;
  24. final String typeName;
  25. final double amtnYj;
  26. final String accNo;
  27. final String accName;
  28. final String dep;
  29. final String depName;
  30. final String objNo;
  31. final String objName;
  32. final String startDd;
  33. final String endDd;
  34. final String priority;
  35. final String rem;
  36. bool selected = false;
  37. ImportableItem({
  38. required this.aeNo,
  39. required this.aeDd,
  40. required this.reason,
  41. required this.headAmtnYj,
  42. required this.itm,
  43. this.preItm,
  44. required this.sqMan,
  45. required this.sqName,
  46. required this.typeNo,
  47. required this.typeName,
  48. required this.amtnYj,
  49. required this.accNo,
  50. required this.accName,
  51. required this.dep,
  52. required this.depName,
  53. required this.objNo,
  54. required this.objName,
  55. required this.startDd,
  56. required this.endDd,
  57. required this.priority,
  58. required this.rem,
  59. });
  60. factory ImportableItem.fromJson(Map<String, dynamic> json) => ImportableItem(
  61. aeNo: json['aeNo'] as String? ?? '',
  62. aeDd: _fmtDate(json['aeDd'] as String?),
  63. reason: json['reason'] as String? ?? '',
  64. headAmtnYj: (json['headAmtnYj'] as num?)?.toDouble() ?? 0,
  65. itm: json['itm'] as int? ?? 0,
  66. preItm: json['preItm'] as int?,
  67. sqMan: json['sqMan'] as String? ?? '',
  68. sqName: json['sqName'] as String? ?? '',
  69. typeNo: json['typeNo'] as String? ?? '',
  70. typeName: json['typeName'] as String? ?? '',
  71. amtnYj: (json['amtnYj'] as num?)?.toDouble() ?? 0,
  72. accNo: json['accNo'] as String? ?? '',
  73. accName: json['accName'] as String? ?? '',
  74. dep: json['dep'] as String? ?? '',
  75. depName: json['depName'] as String? ?? '',
  76. objNo: json['objNo'] as String? ?? '',
  77. objName: json['objName'] as String? ?? '',
  78. startDd: _fmtDate(json['startDd'] as String?),
  79. endDd: _fmtDate(json['endDd'] as String?),
  80. priority: json['priority'] as String? ?? '1',
  81. rem: json['rem'] as String? ?? '',
  82. );
  83. static String _fmtDate(String? raw) {
  84. if (raw == null || raw.isEmpty) return '';
  85. try {
  86. final d = DateTime.parse(raw);
  87. return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
  88. } catch (_) {
  89. return raw;
  90. }
  91. }
  92. }
  93. class ExpenseApplyImportPage extends ConsumerStatefulWidget {
  94. const ExpenseApplyImportPage({super.key});
  95. @override
  96. ConsumerState<ExpenseApplyImportPage> createState() =>
  97. _ExpenseApplyImportPageState();
  98. }
  99. class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
  100. with WidgetsBindingObserver {
  101. String _aeNoKeyword = '';
  102. final _startDateCtrl = TextEditingController();
  103. final _endDateCtrl = TextEditingController();
  104. List<ImportableItem> _items = [];
  105. final Map<String, bool> _expandedGroups = {};
  106. bool _loading = false;
  107. bool _filterLoading = false;
  108. bool _initialLoaded = false;
  109. bool _hasMore = true;
  110. int _page = 1;
  111. late final ScrollController _scrollCtrl;
  112. late final EasyRefreshController _refreshCtrl;
  113. bool _sortDesc = true;
  114. @override
  115. void initState() {
  116. super.initState();
  117. final now = DateTime.now();
  118. _startDateCtrl.text =
  119. '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  120. _endDateCtrl.text =
  121. '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
  122. _scrollCtrl = ScrollController()..addListener(_onScroll);
  123. _refreshCtrl = EasyRefreshController();
  124. WidgetsBinding.instance.addObserver(this);
  125. WidgetsBinding.instance.addPostFrameCallback((_) => _load());
  126. }
  127. @override
  128. void dispose() {
  129. WidgetsBinding.instance.removeObserver(this);
  130. _startDateCtrl.dispose();
  131. _endDateCtrl.dispose();
  132. _scrollCtrl.dispose();
  133. _refreshCtrl.dispose();
  134. super.dispose();
  135. }
  136. @override
  137. void didChangeAppLifecycleState(AppLifecycleState state) {
  138. if (state == AppLifecycleState.resumed) {
  139. FocusScope.of(context).unfocus();
  140. _aeNoKeyword = '';
  141. final now = DateTime.now();
  142. _startDateCtrl.text =
  143. '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  144. _endDateCtrl.text =
  145. '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
  146. _items = [];
  147. _page = 1;
  148. _hasMore = true;
  149. _initialLoaded = false;
  150. _generation++;
  151. _load();
  152. }
  153. }
  154. void _onScroll() {
  155. if (!_loading &&
  156. _hasMore &&
  157. _scrollCtrl.position.pixels >=
  158. _scrollCtrl.position.maxScrollExtent - 200) {
  159. _load(append: true);
  160. }
  161. }
  162. int _generation = 0;
  163. Future<void> _load({bool append = false}) async {
  164. if (_loading) return;
  165. final gen = ++_generation;
  166. setState(() => _loading = true);
  167. try {
  168. final api = ref.read(expenseApiProvider);
  169. final result = await api.getImportableExpenseApplyList(
  170. keyword: _aeNoKeyword,
  171. startDate: _startDateCtrl.text,
  172. endDate: _endDateCtrl.text,
  173. page: append ? _page : 1,
  174. sortDir: _sortDesc ? 'DESC' : 'ASC',
  175. );
  176. if (!mounted) return;
  177. if (gen != _generation) return;
  178. final list =
  179. (result['list'] as List<dynamic>?)
  180. ?.map((e) => ImportableItem.fromJson(e as Map<String, dynamic>))
  181. .toList() ??
  182. [];
  183. setState(() {
  184. if (append) {
  185. _items.addAll(list);
  186. _page++;
  187. } else {
  188. _items = list;
  189. _page = 2;
  190. }
  191. _loading = false;
  192. _filterLoading = false;
  193. _initialLoaded = true;
  194. _hasMore = list.length >= 20;
  195. });
  196. } catch (_) {
  197. if (mounted)
  198. setState(() {
  199. _loading = false;
  200. _filterLoading = false;
  201. });
  202. }
  203. }
  204. void _search() {
  205. FocusScope.of(context).unfocus();
  206. if (_startDateCtrl.text.isNotEmpty &&
  207. _endDateCtrl.text.isNotEmpty &&
  208. _startDateCtrl.text.compareTo(_endDateCtrl.text) > 0) {
  209. TDToast.showText(
  210. AppLocalizations.of(context).get('filterDateStartAfterEnd'),
  211. context: context,
  212. );
  213. return;
  214. }
  215. setState(() => _filterLoading = true);
  216. _page = 1;
  217. _load();
  218. }
  219. Future<void> _refresh() async {
  220. FocusScope.of(context).unfocus();
  221. _page = 1;
  222. _loading = false;
  223. await _load();
  224. }
  225. void _toggleItem(int idx) {
  226. setState(() => _items[idx].selected = !_items[idx].selected);
  227. }
  228. void _toggleGroup(String aeNo) {
  229. setState(() {
  230. final items = _items.where((e) => e.aeNo == aeNo).toList();
  231. final allSelected = items.every((e) => e.selected);
  232. final newVal = !allSelected;
  233. for (final e in items) {
  234. e.selected = newVal;
  235. }
  236. });
  237. }
  238. bool _isGroupAllSelected(String aeNo) {
  239. final items = _items.where((e) => e.aeNo == aeNo);
  240. if (items.isEmpty) return false;
  241. return items.every((e) => e.selected);
  242. }
  243. bool _isGroupAnySelected(String aeNo) {
  244. return _items.any((e) => e.aeNo == aeNo && e.selected);
  245. }
  246. void _confirmImport() {
  247. final l10n = AppLocalizations.of(context);
  248. final selected = _items.where((e) => e.selected).toList();
  249. if (selected.isEmpty) {
  250. TDToast.showText(l10n.get('pleaseSelect'), context: context);
  251. return;
  252. }
  253. Navigator.of(context).pop(selected);
  254. }
  255. Widget _buildSearchBar(AppLocalizations l10n, AppColorsExtension colors) {
  256. final tdTheme = TDTheme.of(context);
  257. return Container(
  258. decoration: BoxDecoration(
  259. color: colors.bgCard,
  260. border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
  261. ),
  262. child: Column(
  263. mainAxisSize: MainAxisSize.min,
  264. children: [
  265. Padding(
  266. padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  267. child: DateRangePicker(
  268. startCtrl: _startDateCtrl,
  269. endCtrl: _endDateCtrl,
  270. onChanged: () {
  271. setState(() {});
  272. _search();
  273. },
  274. ),
  275. ),
  276. const SizedBox(height: 8),
  277. Padding(
  278. padding: const EdgeInsets.fromLTRB(0, 0, 12, 8),
  279. child: Row(
  280. children: [
  281. Expanded(
  282. child: TDSearchBar(
  283. placeHolder: l10n.get('searchImportHint'),
  284. style: TDSearchStyle.round,
  285. onTextChanged: (String text) {
  286. setState(() {
  287. _aeNoKeyword = text;
  288. });
  289. if (text.isEmpty) _search();
  290. },
  291. onSubmitted: (_) => _search(),
  292. ),
  293. ),
  294. GestureDetector(
  295. onTap: () {
  296. setState(() => _sortDesc = !_sortDesc);
  297. _search();
  298. },
  299. child: Container(
  300. width: 40,
  301. height: 40,
  302. decoration: BoxDecoration(
  303. color: colors.primary,
  304. borderRadius: BorderRadius.circular(20),
  305. ),
  306. child: Center(
  307. child: Icon(
  308. _sortDesc ? Icons.arrow_downward : Icons.arrow_upward,
  309. color: Colors.white,
  310. size: 20,
  311. ),
  312. ),
  313. ),
  314. ),
  315. ],
  316. ),
  317. ),
  318. ],
  319. ),
  320. );
  321. }
  322. Widget _buildListContent(
  323. AppLocalizations l10n,
  324. AppColorsExtension colors,
  325. Map<String, List<ImportableItem>> grouped,
  326. ) {
  327. if (_loading && !_initialLoaded) {
  328. return EasyRefresh(
  329. header: TDRefreshHeader(),
  330. controller: _refreshCtrl,
  331. onRefresh: _refresh,
  332. child: SkeletonLoadingList(
  333. cardBuilder: () => const SkeletonImportCard(),
  334. ),
  335. );
  336. }
  337. if (_items.isEmpty) {
  338. return EasyRefresh(
  339. header: TDRefreshHeader(),
  340. controller: _refreshCtrl,
  341. onRefresh: _refresh,
  342. child: ListView(
  343. children: [
  344. const SizedBox(height: 120),
  345. EmptyState(message: l10n.get('noData')),
  346. ],
  347. ),
  348. );
  349. }
  350. return EasyRefresh(
  351. header: TDRefreshHeader(),
  352. controller: _refreshCtrl,
  353. onRefresh: _refresh,
  354. child: ListView.builder(
  355. controller: _scrollCtrl,
  356. padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
  357. itemCount: grouped.length + 1,
  358. itemBuilder: (_, i) {
  359. if (i == grouped.length) {
  360. return ListFooter(itemCount: _items.length, hasMore: _hasMore);
  361. }
  362. final aeNo = grouped.keys.elementAt(i);
  363. return _buildGroupCard(aeNo, grouped[aeNo]!, l10n, colors);
  364. },
  365. ),
  366. );
  367. }
  368. Widget _buildGroupCard(
  369. String aeNo,
  370. List<ImportableItem> items,
  371. AppLocalizations l10n,
  372. AppColorsExtension colors,
  373. ) {
  374. return Padding(
  375. padding: const EdgeInsets.only(bottom: 16),
  376. child: Container(
  377. decoration: BoxDecoration(
  378. color: colors.bgCard,
  379. borderRadius: BorderRadius.circular(12),
  380. ),
  381. child: Column(
  382. crossAxisAlignment: CrossAxisAlignment.start,
  383. children: [
  384. GestureDetector(
  385. behavior: HitTestBehavior.opaque,
  386. onTap: () => _toggleGroup(aeNo),
  387. child: Padding(
  388. padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
  389. child: Column(
  390. crossAxisAlignment: CrossAxisAlignment.start,
  391. children: [
  392. Row(
  393. children: [
  394. _buildHeaderCheckbox(aeNo, colors),
  395. const SizedBox(width: 8),
  396. Expanded(
  397. child: Text(
  398. aeNo,
  399. style: TextStyle(
  400. fontSize: 15,
  401. fontWeight: FontWeight.w600,
  402. color: colors.textPrimary,
  403. ),
  404. ),
  405. ),
  406. Column(
  407. crossAxisAlignment: CrossAxisAlignment.end,
  408. children: [
  409. Text(
  410. items.first.aeDd,
  411. style: TextStyle(
  412. fontSize: 13,
  413. color: colors.textSecondary,
  414. ),
  415. ),
  416. const SizedBox(height: 2),
  417. _priorityChip(items.first.priority, l10n, colors),
  418. ],
  419. ),
  420. ],
  421. ),
  422. if (items.first.reason.isNotEmpty)
  423. Padding(
  424. padding: const EdgeInsets.only(top: 4, left: 30),
  425. child: Text(
  426. items.first.reason,
  427. maxLines: 1,
  428. overflow: TextOverflow.ellipsis,
  429. style: TextStyle(
  430. fontSize: 13,
  431. color: colors.textSecondary,
  432. ),
  433. ),
  434. ),
  435. ],
  436. ),
  437. ),
  438. ),
  439. Divider(height: 1, color: colors.border),
  440. if (items.length <= 3)
  441. ...items.asMap().entries.map(
  442. (entry) => _buildDetailRow(
  443. entry.value,
  444. _items.indexOf(entry.value),
  445. l10n,
  446. colors,
  447. ),
  448. )
  449. else ...[
  450. ...items
  451. .take(3)
  452. .toList()
  453. .asMap()
  454. .entries
  455. .map(
  456. (entry) => _buildDetailRow(
  457. entry.value,
  458. _items.indexOf(entry.value),
  459. l10n,
  460. colors,
  461. ),
  462. ),
  463. GestureDetector(
  464. onTap: () => setState(
  465. () =>
  466. _expandedGroups[aeNo] = !(_expandedGroups[aeNo] ?? false),
  467. ),
  468. child: Padding(
  469. padding: const EdgeInsets.symmetric(
  470. horizontal: 16,
  471. vertical: 4,
  472. ),
  473. child: Row(
  474. mainAxisAlignment: MainAxisAlignment.end,
  475. children: [
  476. Text(
  477. _expandedGroups[aeNo] == true
  478. ? '${l10n.getString('collapseRemaining', args: {'count': (items.length - 3).toString()})} ▲'
  479. : '${l10n.getString('expandRemaining', args: {'count': (items.length - 3).toString()})} ▼',
  480. style: TextStyle(fontSize: 13, color: colors.primary),
  481. ),
  482. ],
  483. ),
  484. ),
  485. ),
  486. if (_expandedGroups[aeNo] == true)
  487. ...items
  488. .skip(3)
  489. .toList()
  490. .asMap()
  491. .entries
  492. .map(
  493. (entry) => _buildDetailRow(
  494. entry.value,
  495. _items.indexOf(entry.value),
  496. l10n,
  497. colors,
  498. ),
  499. ),
  500. ],
  501. if (items.length > 1)
  502. Padding(
  503. padding: const EdgeInsets.fromLTRB(0, 4, 16, 12),
  504. child: Row(
  505. mainAxisAlignment: MainAxisAlignment.end,
  506. children: [
  507. Text(
  508. '${l10n.get('total')} ${items.length} ${l10n.get('unitItem')}',
  509. style: TextStyle(
  510. fontSize: 12,
  511. color: colors.textSecondary,
  512. ),
  513. ),
  514. ],
  515. ),
  516. )
  517. else
  518. const SizedBox(height: 8),
  519. ],
  520. ),
  521. ),
  522. );
  523. }
  524. Widget _buildDetailRow(
  525. ImportableItem d,
  526. int idx,
  527. AppLocalizations l10n,
  528. AppColorsExtension colors,
  529. ) {
  530. return GestureDetector(
  531. behavior: HitTestBehavior.opaque,
  532. onTap: () => _toggleItem(idx),
  533. child: Padding(
  534. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
  535. child: Row(
  536. children: [
  537. SizedBox(width: 30, child: _buildItemCheckbox(idx, colors)),
  538. const SizedBox(width: 4),
  539. Container(
  540. width: 24,
  541. alignment: Alignment.center,
  542. child: FittedBox(
  543. fit: BoxFit.scaleDown,
  544. child: Text(
  545. '#${d.itm}',
  546. maxLines: 1,
  547. style: TextStyle(
  548. fontSize: 13,
  549. fontWeight: FontWeight.w500,
  550. color: colors.textSecondary,
  551. ),
  552. ),
  553. ),
  554. ),
  555. const SizedBox(width: 8),
  556. Expanded(
  557. child: Column(
  558. crossAxisAlignment: CrossAxisAlignment.start,
  559. children: [
  560. Text(
  561. '${d.typeName.isNotEmpty ? '${d.typeNo}/${d.typeName}' : d.typeNo} ${d.accName}',
  562. style: TextStyle(fontSize: 14, color: colors.textPrimary),
  563. ),
  564. if (d.rem.isNotEmpty)
  565. Padding(
  566. padding: const EdgeInsets.only(top: 2),
  567. child: Text(
  568. d.rem,
  569. maxLines: 2,
  570. overflow: TextOverflow.ellipsis,
  571. style: TextStyle(
  572. fontSize: 13,
  573. color: colors.textSecondary,
  574. ),
  575. ),
  576. ),
  577. const SizedBox(height: 3),
  578. if (d.sqMan.isNotEmpty)
  579. Padding(
  580. padding: const EdgeInsets.only(bottom: 2),
  581. child: Text(
  582. '${l10n.get('applicant')}: ${d.sqName.isNotEmpty ? '${d.sqMan}/${d.sqName}' : d.sqMan}',
  583. style: TextStyle(
  584. fontSize: 13,
  585. color: colors.textSecondary,
  586. ),
  587. ),
  588. ),
  589. Padding(
  590. padding: const EdgeInsets.only(bottom: 2),
  591. child: Text(
  592. '${l10n.get('acctSubject')}: ${d.accNo}/${d.accName}',
  593. style: TextStyle(
  594. fontSize: 13,
  595. color: colors.textSecondary,
  596. ),
  597. ),
  598. ),
  599. if (d.depName.isNotEmpty)
  600. Padding(
  601. padding: const EdgeInsets.only(bottom: 2),
  602. child: Text(
  603. '${l10n.get('dept')}: ${d.dep}/${d.depName}',
  604. style: TextStyle(
  605. fontSize: 13,
  606. color: colors.textSecondary,
  607. ),
  608. ),
  609. ),
  610. if (d.objName.isNotEmpty)
  611. Padding(
  612. padding: const EdgeInsets.only(bottom: 2),
  613. child: Text(
  614. '${l10n.get('project')}: ${d.objNo}/${d.objName}',
  615. style: TextStyle(
  616. fontSize: 13,
  617. color: colors.textSecondary,
  618. ),
  619. ),
  620. ),
  621. if (d.startDd.isNotEmpty || d.endDd.isNotEmpty)
  622. Text(
  623. '${l10n.get('date')}: ${d.startDd} ~ ${d.endDd}',
  624. style: TextStyle(
  625. fontSize: 13,
  626. color: colors.textSecondary,
  627. ),
  628. ),
  629. ],
  630. ),
  631. ),
  632. const SizedBox(width: 8),
  633. Text(
  634. formatAmount(d.amtnYj),
  635. style: TextStyle(
  636. fontSize: 15,
  637. fontWeight: FontWeight.w600,
  638. color: colors.amountPrimary,
  639. ),
  640. ),
  641. ],
  642. ),
  643. ),
  644. );
  645. }
  646. Widget _buildHeaderCheckbox(String aeNo, AppColorsExtension colors) {
  647. final allSel = _isGroupAllSelected(aeNo);
  648. final anySel = _isGroupAnySelected(aeNo);
  649. IconData icon;
  650. Color iconColor;
  651. if (allSel) {
  652. icon = Icons.check_box;
  653. iconColor = colors.primary;
  654. } else if (anySel) {
  655. icon = Icons.indeterminate_check_box;
  656. iconColor = colors.primary;
  657. } else {
  658. icon = Icons.check_box_outline_blank;
  659. iconColor = colors.textPlaceholder;
  660. }
  661. return GestureDetector(
  662. onTap: () => _toggleGroup(aeNo),
  663. child: Icon(icon, size: 22, color: iconColor),
  664. );
  665. }
  666. Widget _buildItemCheckbox(int idx, AppColorsExtension colors) {
  667. final item = _items[idx];
  668. return GestureDetector(
  669. onTap: () => _toggleItem(idx),
  670. child: Icon(
  671. item.selected ? Icons.check_box : Icons.check_box_outline_blank,
  672. size: 18,
  673. color: item.selected ? colors.primary : colors.textPlaceholder,
  674. ),
  675. );
  676. }
  677. Widget _priorityChip(
  678. String priority,
  679. AppLocalizations l10n,
  680. AppColorsExtension colors,
  681. ) {
  682. final label = priority == '3'
  683. ? l10n.get('filterCritical')
  684. : priority == '2'
  685. ? l10n.get('urgent')
  686. : l10n.get('normal');
  687. final chipColor = priority == '3'
  688. ? colors.danger
  689. : priority == '2'
  690. ? colors.warning
  691. : colors.primary;
  692. return Container(
  693. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
  694. decoration: BoxDecoration(
  695. color: chipColor.withValues(alpha: 0.1),
  696. borderRadius: BorderRadius.circular(4),
  697. border: Border.all(color: chipColor, width: 0.5),
  698. ),
  699. child: Text(
  700. label,
  701. style: TextStyle(
  702. fontSize: 11,
  703. fontWeight: FontWeight.w500,
  704. color: chipColor,
  705. ),
  706. ),
  707. );
  708. }
  709. @override
  710. Widget build(BuildContext context) {
  711. final l10n = AppLocalizations.of(context);
  712. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  713. final grouped = <String, List<ImportableItem>>{};
  714. for (final item in _items) {
  715. grouped.putIfAbsent(item.aeNo, () => []).add(item);
  716. }
  717. return Column(
  718. crossAxisAlignment: CrossAxisAlignment.stretch,
  719. children: [
  720. Expanded(
  721. child: GestureDetector(
  722. behavior: HitTestBehavior.translucent,
  723. onTap: () => FocusScope.of(context).unfocus(),
  724. child: Column(
  725. children: [
  726. _buildSearchBar(l10n, colors),
  727. const SizedBox(height: 8),
  728. Expanded(
  729. child: Stack(
  730. children: [
  731. _buildListContent(l10n, colors, grouped),
  732. if (_filterLoading) ...[
  733. Container(color: colors.bgPage.withValues(alpha: 0.6)),
  734. const Center(
  735. child: TDLoading(
  736. size: TDLoadingSize.medium,
  737. icon: TDLoadingIcon.activity,
  738. ),
  739. ),
  740. ],
  741. ],
  742. ),
  743. ),
  744. ],
  745. ),
  746. ),
  747. ),
  748. SafeArea(
  749. top: false,
  750. left: false,
  751. right: false,
  752. child: Padding(
  753. padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
  754. child: TDButton(
  755. text: l10n.get('confirmImport'),
  756. size: TDButtonSize.large,
  757. theme: TDButtonTheme.primary,
  758. onTap: _confirmImport,
  759. ),
  760. ),
  761. ),
  762. ],
  763. );
  764. }
  765. }