overtime_apply_detail_dialog.dart 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388
  1. // ignore_for_file: use_build_context_synchronously
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../../core/i18n/app_localizations.dart';
  6. import '../../../core/navigation/host_app_channel.dart';
  7. import '../../../core/theme/app_colors.dart';
  8. import '../../../core/theme/app_colors_extension.dart';
  9. import '../../../shared/widgets/searchable_picker_sheet.dart';
  10. import '../../expense_apply/expense_apply_api.dart';
  11. import '../overtime_apply_api.dart';
  12. /// 加班明细输入数据。
  13. class OvertimeDetailData {
  14. final String? jbNo;
  15. final int? itm;
  16. final String salNo;
  17. final String salName;
  18. final String dep;
  19. final String depName;
  20. final String jbType;
  21. final String jbDate;
  22. final String startTime;
  23. final String endTime;
  24. final double jbHours;
  25. final double jbDays;
  26. final String attPeriod;
  27. final String reason;
  28. final String compensationType;
  29. final double compensationCount;
  30. final String adr;
  31. final String rem;
  32. const OvertimeDetailData({
  33. this.jbNo,
  34. this.itm,
  35. this.salNo = '',
  36. this.salName = '',
  37. this.dep = '',
  38. this.depName = '',
  39. this.jbType = 'WORKING_DAY',
  40. this.jbDate = '',
  41. this.startTime = '',
  42. this.endTime = '',
  43. this.jbHours = 0.0,
  44. this.jbDays = 0.0,
  45. this.attPeriod = '',
  46. this.reason = '',
  47. this.compensationType = '',
  48. this.compensationCount = 0.0,
  49. this.adr = '',
  50. this.rem = '',
  51. });
  52. }
  53. /// 添加/编辑加班明细弹窗。
  54. class OvertimeApplyDetailDialog extends StatefulWidget {
  55. final OvertimeApplyApi api;
  56. final AppLocalizations l10n;
  57. final OvertimeDetailData? initialData;
  58. const OvertimeApplyDetailDialog({
  59. super.key,
  60. required this.api,
  61. required this.l10n,
  62. this.initialData,
  63. });
  64. /// 显示弹窗,返回 [OvertimeDetailData] 或 `null`(取消时)。
  65. static Future<OvertimeDetailData?> show(
  66. BuildContext context, {
  67. required OvertimeApplyApi api,
  68. required AppLocalizations l10n,
  69. OvertimeDetailData? initialData,
  70. }) {
  71. FocusScope.of(context).unfocus();
  72. return Navigator.push<OvertimeDetailData>(
  73. context,
  74. TDSlidePopupRoute<OvertimeDetailData>(
  75. slideTransitionFrom: SlideTransitionFrom.bottom,
  76. isDismissible: true,
  77. builder: (_) => OvertimeApplyDetailDialog(
  78. api: api,
  79. l10n: l10n,
  80. initialData: initialData,
  81. ),
  82. ),
  83. );
  84. }
  85. @override
  86. State<OvertimeApplyDetailDialog> createState() =>
  87. _OvertimeApplyDetailDialogState();
  88. }
  89. class _OvertimeApplyDetailDialogState extends State<OvertimeApplyDetailDialog> {
  90. // 员工
  91. EmployeeItem? _selEmployee;
  92. // 部门
  93. String _dep = '';
  94. String _depName = '';
  95. // 加班类型
  96. String _jbType = 'WORKING_DAY';
  97. // 加班日期
  98. String _jbDate = '';
  99. // 开始/结束时间
  100. String _startTime = '';
  101. String _endTime = '';
  102. // 加班时长、天数
  103. final _hoursCtrl = TextEditingController();
  104. final _hoursFocus = FocusNode();
  105. final _daysCtrl = TextEditingController();
  106. final _daysFocus = FocusNode();
  107. // 考勤周期
  108. String _attPeriod = '';
  109. // 事由
  110. final _reasonCtrl = TextEditingController();
  111. final _reasonFocus = FocusNode();
  112. // 补偿类型
  113. String _compensationType = '';
  114. // 折算补偿次数
  115. final _compCountCtrl = TextEditingController();
  116. final _compCountFocus = FocusNode();
  117. // 地点
  118. final _adrCtrl = TextEditingController();
  119. final _adrFocus = FocusNode();
  120. // 备注
  121. final _remarkCtrl = TextEditingController();
  122. final _remarkFocus = FocusNode();
  123. // 滚动
  124. final _scrollCtrl = ScrollController();
  125. // 加班类型选项
  126. static const _jbTypeOptions = [
  127. 'WORKING_DAY',
  128. 'REST_DAY',
  129. 'PUBLIC_HOLIDAY',
  130. 'SPECIAL_HOLIDAY',
  131. 'OTHER',
  132. ];
  133. // 补偿类型选项
  134. static const _compensationTypeOptions = [
  135. 'OVERTIME_PAY',
  136. 'COMPENSATORY_LEAVE',
  137. 'NO_COMPENSATION',
  138. 'OTHER',
  139. ];
  140. AppLocalizations get _l10n => widget.l10n;
  141. bool get _isEdit => widget.initialData != null;
  142. String _todayStr() {
  143. final now = DateTime.now();
  144. return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
  145. }
  146. @override
  147. void initState() {
  148. super.initState();
  149. final d = widget.initialData;
  150. _selEmployee = (d != null && d.salNo.isNotEmpty)
  151. ? EmployeeItem(salNo: d.salNo, name: d.salName)
  152. : null;
  153. _dep = d?.dep ?? '';
  154. _depName = d?.depName ?? '';
  155. _jbType = d?.jbType ?? 'WORKING_DAY';
  156. _jbDate = d?.jbDate ?? '';
  157. if (!_isEdit && _jbDate.isEmpty) {
  158. final now = DateTime.now();
  159. _jbDate = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
  160. }
  161. _startTime = d?.startTime ?? '';
  162. _endTime = d?.endTime ?? '';
  163. // 新增模式:开始/结束时间给默认值
  164. if (!_isEdit) {
  165. final dateStr = _jbDate.isNotEmpty ? _jbDate : _todayStr();
  166. final weekday = DateTime.tryParse(dateStr)?.weekday ?? DateTime.now().weekday;
  167. if (_startTime.isEmpty) {
  168. final h = (weekday <= DateTime.friday) ? 18 : 9;
  169. _startTime = '$dateStr ${h.toString().padLeft(2, '0')}:00';
  170. }
  171. if (_endTime.isEmpty) {
  172. final h = (weekday <= DateTime.friday) ? 19 : 18;
  173. _endTime = '$dateStr ${h.toString().padLeft(2, '0')}:00';
  174. }
  175. _attPeriod = dateStr.substring(0, 7);
  176. _autoCalcHours();
  177. }
  178. if (d != null) {
  179. _hoursCtrl.text = d.jbHours > 0 ? d.jbHours.toString() : '';
  180. _daysCtrl.text = d.jbDays > 0 ? d.jbDays.toString() : '';
  181. _attPeriod = d.attPeriod;
  182. }
  183. if (d != null) {
  184. _reasonCtrl.text = d.reason;
  185. }
  186. _compensationType = d?.compensationType ?? '';
  187. if (d != null) {
  188. _compCountCtrl.text = d.compensationCount > 0
  189. ? d.compensationCount.toString()
  190. : '';
  191. }
  192. if (d != null) {
  193. _adrCtrl.text = d.adr;
  194. _remarkCtrl.text = d.rem;
  195. }
  196. _reasonFocus.addListener(() => _ensureVisible(_reasonFocus));
  197. _hoursFocus.addListener(() {
  198. _ensureVisible(_hoursFocus);
  199. if (!_hoursFocus.hasFocus) _roundToHalf(_hoursCtrl);
  200. });
  201. _daysFocus.addListener(() {
  202. _ensureVisible(_daysFocus);
  203. if (!_daysFocus.hasFocus) _roundToHalf(_daysCtrl);
  204. });
  205. _compCountFocus.addListener(() => _ensureVisible(_compCountFocus));
  206. _adrFocus.addListener(() => _ensureVisible(_adrFocus));
  207. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  208. // 非编辑模式自动带出当前用户
  209. if (!_isEdit) {
  210. WidgetsBinding.instance.addPostFrameCallback((_) => _doAutoFill());
  211. }
  212. }
  213. void _ensureVisible(FocusNode node) {
  214. if (!node.hasFocus) return;
  215. _doEnsureVisible(node, 0, -1);
  216. }
  217. void _doEnsureVisible(FocusNode node, int attempt, double lastInsets) {
  218. if (attempt >= 15) return;
  219. WidgetsBinding.instance.addPostFrameCallback((_) {
  220. if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
  221. final insets = MediaQuery.of(context).viewInsets.bottom;
  222. if (insets != lastInsets) {
  223. _doEnsureVisible(node, attempt + 1, insets);
  224. return;
  225. }
  226. final ctx = node.context;
  227. if (ctx == null) return;
  228. Future.delayed(const Duration(milliseconds: 500), () {
  229. if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
  230. Scrollable.ensureVisible(
  231. ctx,
  232. alignment: 0.5,
  233. duration: const Duration(milliseconds: 300),
  234. );
  235. });
  236. });
  237. }
  238. Future<void> _doAutoFill() async {
  239. await HostAppChannel.ensureConfig();
  240. final usr = HostAppChannel.usr;
  241. if (usr.isEmpty) return;
  242. try {
  243. final employees = await widget.api.getEmployees(salNo: usr);
  244. if (employees.isNotEmpty && mounted) {
  245. setState(() {
  246. _selEmployee = employees.first;
  247. });
  248. // 自动带出部门
  249. final depCode = HostAppChannel.dep;
  250. if (depCode.isNotEmpty) {
  251. final depts = await widget.api.getDepartments();
  252. final matched = depts.where((d) => d.dep == depCode);
  253. if (matched.isNotEmpty && mounted) {
  254. setState(() {
  255. _dep = matched.first.dep;
  256. _depName = matched.first.name;
  257. });
  258. }
  259. }
  260. }
  261. } catch (_) {
  262. // 静默失败
  263. }
  264. }
  265. @override
  266. void dispose() {
  267. _hoursCtrl.dispose();
  268. _hoursFocus.dispose();
  269. _daysCtrl.dispose();
  270. _daysFocus.dispose();
  271. _reasonCtrl.dispose();
  272. _reasonFocus.dispose();
  273. _compCountCtrl.dispose();
  274. _compCountFocus.dispose();
  275. _adrCtrl.dispose();
  276. _adrFocus.dispose();
  277. _remarkCtrl.dispose();
  278. _remarkFocus.dispose();
  279. _scrollCtrl.dispose();
  280. super.dispose();
  281. }
  282. void _confirm() {
  283. if (_selEmployee == null) {
  284. TDToast.showText(
  285. '${_l10n.get('pleaseSelect')}${_l10n.get('employee')}',
  286. context: context,
  287. );
  288. return;
  289. }
  290. if (_jbDate.isEmpty) {
  291. TDToast.showText(
  292. '${_l10n.get('pleaseSelect')}${_l10n.get('date')}',
  293. context: context,
  294. );
  295. return;
  296. }
  297. if (_startTime.isEmpty) {
  298. TDToast.showText(
  299. '${_l10n.get('pleaseSelect')}${_l10n.get('startTime')}',
  300. context: context,
  301. );
  302. return;
  303. }
  304. if (_endTime.isEmpty) {
  305. TDToast.showText(
  306. '${_l10n.get('pleaseSelect')}${_l10n.get('endTime')}',
  307. context: context,
  308. );
  309. return;
  310. }
  311. if (_startTime.compareTo(_endTime) >= 0) {
  312. TDToast.showText(_l10n.get('endTimeMustLater'), context: context);
  313. return;
  314. }
  315. final hours = double.tryParse(_hoursCtrl.text) ?? 0;
  316. if (hours <= 0) {
  317. TDToast.showText(_l10n.get('overtimeHoursPositive'), context: context);
  318. return;
  319. }
  320. Navigator.pop(
  321. context,
  322. OvertimeDetailData(
  323. jbNo: widget.initialData?.jbNo,
  324. itm: widget.initialData?.itm,
  325. salNo: _selEmployee!.salNo,
  326. salName: _selEmployee!.name,
  327. dep: _dep,
  328. depName: _depName,
  329. jbType: _jbType,
  330. jbDate: _jbDate,
  331. startTime: _startTime,
  332. endTime: _endTime,
  333. jbHours: hours,
  334. jbDays: double.tryParse(_daysCtrl.text) ?? 0,
  335. attPeriod: _attPeriod,
  336. reason: _reasonCtrl.text.trim(),
  337. compensationType: _compensationType,
  338. compensationCount: double.tryParse(_compCountCtrl.text) ?? 0,
  339. adr: _adrCtrl.text,
  340. rem: _remarkCtrl.text,
  341. ),
  342. );
  343. }
  344. @override
  345. Widget build(BuildContext context) {
  346. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  347. return AnimatedPadding(
  348. padding: EdgeInsets.only(
  349. bottom: MediaQuery.of(context).viewInsets.bottom,
  350. ),
  351. duration: const Duration(milliseconds: 200),
  352. child: SafeArea(
  353. child: ConstrainedBox(
  354. constraints: BoxConstraints(
  355. maxHeight: MediaQuery.of(context).size.height * 0.85,
  356. ),
  357. child: Container(
  358. decoration: BoxDecoration(
  359. color: colors.bgPage,
  360. borderRadius: const BorderRadius.vertical(
  361. top: Radius.circular(16),
  362. ),
  363. ),
  364. child: Column(
  365. mainAxisSize: MainAxisSize.min,
  366. crossAxisAlignment: CrossAxisAlignment.stretch,
  367. children: [
  368. _buildHeader(colors),
  369. Flexible(
  370. child: GestureDetector(
  371. onTap: () => FocusScope.of(context).unfocus(),
  372. behavior: HitTestBehavior.translucent,
  373. child: SingleChildScrollView(
  374. controller: _scrollCtrl,
  375. keyboardDismissBehavior:
  376. ScrollViewKeyboardDismissBehavior.manual,
  377. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  378. child: Column(
  379. mainAxisSize: MainAxisSize.min,
  380. crossAxisAlignment: CrossAxisAlignment.stretch,
  381. children: [
  382. // ── 必填 ──
  383. _buildEmployeePicker(colors),
  384. const SizedBox(height: 12),
  385. _buildJbTypeSelector(colors),
  386. const SizedBox(height: 12),
  387. _buildJbDatePicker(colors),
  388. const SizedBox(height: 12),
  389. _buildStartTimePicker(colors),
  390. const SizedBox(height: 12),
  391. _buildEndTimePicker(colors),
  392. // ── 非必填 ──
  393. const SizedBox(height: 12),
  394. _buildDeptPicker(colors),
  395. const SizedBox(height: 12),
  396. _buildHoursInput(colors),
  397. const SizedBox(height: 12),
  398. _buildDaysInput(colors),
  399. const SizedBox(height: 12),
  400. _buildAttPeriodInput(colors),
  401. const SizedBox(height: 12),
  402. _buildReasonInput(colors),
  403. const SizedBox(height: 12),
  404. _buildCompensationTypeSelector(colors),
  405. const SizedBox(height: 12),
  406. _buildCompCountInput(colors),
  407. const SizedBox(height: 12),
  408. _buildAdrInput(colors),
  409. const SizedBox(height: 12),
  410. _buildRemarkInput(colors),
  411. ],
  412. ),
  413. ),
  414. ),
  415. ),
  416. Container(
  417. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  418. decoration: BoxDecoration(
  419. color: colors.bgCard,
  420. border: Border(
  421. top: BorderSide(color: colors.border, width: 0.5),
  422. ),
  423. ),
  424. child: _buildActions(),
  425. ),
  426. ],
  427. ),
  428. ),
  429. ),
  430. ),
  431. );
  432. }
  433. // ── 标题栏 ──
  434. Widget _buildHeader(AppColorsExtension colors) {
  435. return Column(
  436. mainAxisSize: MainAxisSize.min,
  437. children: [
  438. Center(
  439. child: Container(
  440. margin: const EdgeInsets.only(top: 8, bottom: 4),
  441. width: 36,
  442. height: 4,
  443. decoration: BoxDecoration(
  444. color: colors.border,
  445. borderRadius: BorderRadius.circular(2),
  446. ),
  447. ),
  448. ),
  449. Padding(
  450. padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
  451. child: Row(
  452. children: [
  453. const SizedBox(width: 24),
  454. Expanded(
  455. child: Center(
  456. child: Text(
  457. _isEdit ? _l10n.get('editOvertimeDetail') : _l10n.get('addOvertimeDetail'),
  458. style: TextStyle(
  459. fontSize: AppFontSizes.title,
  460. fontWeight: FontWeight.w600,
  461. color: colors.textPrimary,
  462. ),
  463. ),
  464. ),
  465. ),
  466. GestureDetector(
  467. onTap: () => Navigator.pop(context),
  468. child: Padding(
  469. padding: const EdgeInsets.all(4),
  470. child: Icon(
  471. Icons.close,
  472. size: 20,
  473. color: colors.textSecondary,
  474. ),
  475. ),
  476. ),
  477. ],
  478. ),
  479. ),
  480. ],
  481. );
  482. }
  483. // ── 通用 Picker 卡片 ──
  484. Widget _pickerCard({
  485. required String label,
  486. required bool required,
  487. required String currentLabel,
  488. required bool hasValue,
  489. required VoidCallback onTap,
  490. VoidCallback? onClear,
  491. }) {
  492. final tdTheme = TDTheme.of(context);
  493. return GestureDetector(
  494. onTap: () {
  495. FocusManager.instance.primaryFocus?.unfocus();
  496. onTap();
  497. },
  498. child: Container(
  499. padding: const EdgeInsets.only(
  500. left: 16,
  501. right: 10,
  502. top: 12,
  503. bottom: 12,
  504. ),
  505. decoration: BoxDecoration(
  506. color: tdTheme.bgColorContainer,
  507. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  508. border: Border.all(color: tdTheme.componentStrokeColor),
  509. ),
  510. child: Row(
  511. children: [
  512. TDText(
  513. label,
  514. maxLines: 1,
  515. overflow: TextOverflow.visible,
  516. font: tdTheme.fontBodyLarge,
  517. fontWeight: FontWeight.w400,
  518. style: const TextStyle(letterSpacing: 0),
  519. ),
  520. if (required)
  521. Padding(
  522. padding: const EdgeInsets.only(left: 4),
  523. child: TDText(
  524. '*',
  525. font: tdTheme.fontBodyLarge,
  526. fontWeight: FontWeight.w400,
  527. style: TextStyle(color: tdTheme.errorColor6),
  528. ),
  529. ),
  530. const SizedBox(width: 12),
  531. Expanded(
  532. child: Row(
  533. mainAxisAlignment: MainAxisAlignment.end,
  534. mainAxisSize: MainAxisSize.max,
  535. children: [
  536. Flexible(
  537. child: TDText(
  538. currentLabel,
  539. maxLines: 1,
  540. overflow: TextOverflow.ellipsis,
  541. font: tdTheme.fontBodyLarge,
  542. fontWeight: FontWeight.w400,
  543. textColor: hasValue
  544. ? tdTheme.textColorPrimary
  545. : tdTheme.textColorPlaceholder,
  546. textAlign: TextAlign.end,
  547. ),
  548. ),
  549. const SizedBox(width: 4),
  550. SizedBox(
  551. width: 18,
  552. height: 18,
  553. child: onClear != null
  554. ? GestureDetector(
  555. onTap: onClear,
  556. child: Icon(
  557. Icons.close,
  558. size: 18,
  559. color: tdTheme.textColorPlaceholder,
  560. ),
  561. )
  562. : Icon(
  563. Icons.chevron_right,
  564. size: 18,
  565. color: tdTheme.textColorPlaceholder,
  566. ),
  567. ),
  568. ],
  569. ),
  570. ),
  571. ],
  572. ),
  573. ),
  574. );
  575. }
  576. // ── 0. 员工 ──
  577. Widget _buildEmployeePicker(AppColorsExtension colors) {
  578. return _pickerCard(
  579. label: _l10n.get('employee'),
  580. required: true,
  581. hasValue: _selEmployee != null,
  582. currentLabel: _selEmployee != null
  583. ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
  584. : _l10n.get('pleaseSelect'),
  585. onTap: () async {
  586. final result = await showSearchablePicker<EmployeeItem>(
  587. context,
  588. title: '${_l10n.get('select')}${_l10n.get('employee')}',
  589. searchHint: _l10n.get('search'),
  590. loader: (keyword, page) =>
  591. widget.api.getEmployees(keyword: keyword, page: page, size: 20),
  592. labelBuilder: (e) =>
  593. e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
  594. onRefresh: () => widget.api.clearRefCache(),
  595. );
  596. if (result != null && mounted) {
  597. setState(() => _selEmployee = result);
  598. }
  599. },
  600. onClear: _selEmployee != null
  601. ? () => setState(() => _selEmployee = null)
  602. : null,
  603. );
  604. }
  605. // ── 1. 部门 ──
  606. Widget _buildDeptPicker(AppColorsExtension colors) {
  607. return _pickerCard(
  608. label: _l10n.get('dep'),
  609. required: false,
  610. hasValue: _dep.isNotEmpty,
  611. currentLabel: _dep.isNotEmpty
  612. ? (_depName.isNotEmpty ? '$_dep/$_depName' : _dep)
  613. : _l10n.get('pleaseSelect'),
  614. onTap: () async {
  615. final result = await showSearchablePicker<DepartmentItem>(
  616. context,
  617. title: '${_l10n.get('select')}${_l10n.get('dep')}',
  618. searchHint: _l10n.get('search'),
  619. loader: (keyword, page) =>
  620. widget.api.getDepartments(keyword: keyword, page: page, size: 20),
  621. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  622. onRefresh: () => widget.api.clearRefCache(),
  623. );
  624. if (result != null && mounted) {
  625. setState(() {
  626. _dep = result.dep;
  627. _depName = result.name;
  628. });
  629. }
  630. },
  631. onClear: _dep.isNotEmpty
  632. ? () => setState(() {
  633. _dep = '';
  634. _depName = '';
  635. })
  636. : null,
  637. );
  638. }
  639. // ── 2. 加班类型 ──
  640. Widget _buildJbTypeSelector(AppColorsExtension colors) {
  641. final selLabel = _jbTypeLabel(_jbType);
  642. return _pickerCard(
  643. label: _l10n.get('jbType'),
  644. required: true,
  645. hasValue: true,
  646. currentLabel: selLabel,
  647. onTap: () => _showJbTypePicker(),
  648. );
  649. }
  650. void _showJbTypePicker() {
  651. FocusManager.instance.primaryFocus?.unfocus();
  652. final labels = _jbTypeOptions.map((t) => _jbTypeLabel(t)).toList();
  653. TDPicker.showMultiPicker(
  654. context,
  655. title: _l10n.get('selectJbType'),
  656. data: [labels],
  657. onConfirm: (selected) {
  658. if (selected.isNotEmpty) {
  659. final idx = selected.first is int ? selected.first as int : 0;
  660. if (idx >= 0 && idx < _jbTypeOptions.length) {
  661. setState(() => _jbType = _jbTypeOptions[idx]);
  662. }
  663. }
  664. Navigator.of(context).pop();
  665. },
  666. );
  667. }
  668. String _jbTypeLabel(String type) {
  669. switch (type) {
  670. case 'WORKING_DAY':
  671. return _l10n.get('workingDay');
  672. case 'REST_DAY':
  673. return _l10n.get('restDay');
  674. case 'PUBLIC_HOLIDAY':
  675. return _l10n.get('publicHoliday');
  676. case 'SPECIAL_HOLIDAY':
  677. return _l10n.get('specialHoliday');
  678. case 'OTHER':
  679. return _l10n.get('other');
  680. default:
  681. return type;
  682. }
  683. }
  684. String? _dayOfWeekLabel(String dateTimeStr) {
  685. final dt = DateTime.tryParse(dateTimeStr);
  686. if (dt == null) return null;
  687. switch (dt.weekday) {
  688. case 1:
  689. return _l10n.get('monday');
  690. case 2:
  691. return _l10n.get('tuesday');
  692. case 3:
  693. return _l10n.get('wednesday');
  694. case 4:
  695. return _l10n.get('thursday');
  696. case 5:
  697. return _l10n.get('friday');
  698. case 6:
  699. return _l10n.get('saturday');
  700. case 7:
  701. return _l10n.get('sunday');
  702. default:
  703. return null;
  704. }
  705. }
  706. // ── 4. 申请日期 ──
  707. Widget _buildJbDatePicker(AppColorsExtension colors) {
  708. return _datePickerCard(
  709. label: _l10n.get('applyDate'),
  710. value: _jbDate,
  711. hint: _l10n.get('pleaseSelect'),
  712. colors: colors,
  713. required: true,
  714. onPick: (d) => setState(() => _jbDate = d),
  715. onClear: _jbDate.isNotEmpty ? () => setState(() => _jbDate = '') : null,
  716. );
  717. }
  718. // ── 4. 开始时间 ──
  719. Widget _buildStartTimePicker(AppColorsExtension colors) {
  720. return _datePickerCard(
  721. label: _l10n.get('startTime'),
  722. value: _startTime,
  723. hint: _l10n.get('pleaseSelect'),
  724. colors: colors,
  725. required: true,
  726. showDayOfWeek: true,
  727. onPick: (d) {
  728. setState(() {
  729. _startTime = d;
  730. if (d.length >= 7) _attPeriod = d.substring(0, 7);
  731. _autoCalcHours();
  732. });
  733. },
  734. onClear: _startTime.isNotEmpty
  735. ? () {
  736. setState(() {
  737. _startTime = '';
  738. _autoCalcHours();
  739. });
  740. }
  741. : null,
  742. );
  743. }
  744. // ── 5. 结束时间 ──
  745. Widget _buildEndTimePicker(AppColorsExtension colors) {
  746. return _datePickerCard(
  747. label: _l10n.get('endTime'),
  748. value: _endTime,
  749. hint: _l10n.get('pleaseSelect'),
  750. colors: colors,
  751. required: true,
  752. showDayOfWeek: true,
  753. onPick: (d) {
  754. setState(() {
  755. _endTime = d;
  756. _autoCalcHours();
  757. });
  758. },
  759. onClear: _endTime.isNotEmpty
  760. ? () {
  761. setState(() {
  762. _endTime = '';
  763. _autoCalcHours();
  764. });
  765. }
  766. : null,
  767. );
  768. }
  769. void _autoCalcHours() {
  770. if (_startTime.isEmpty || _endTime.isEmpty) return;
  771. final st = DateTime.tryParse(_startTime);
  772. final et = DateTime.tryParse(_endTime);
  773. if (st == null || et == null || et.isBefore(st)) return;
  774. // 按自然日逐天累加,每天最多 8 小时
  775. double totalHours = 0;
  776. DateTime dayStart = DateTime(st.year, st.month, st.day);
  777. while (dayStart.isBefore(et)) {
  778. final dayEnd = dayStart.add(const Duration(days: 1));
  779. final segStart = st.isAfter(dayStart) ? st : dayStart;
  780. final segEnd = et.isBefore(dayEnd) ? et : dayEnd;
  781. final segHours = segEnd.difference(segStart).inMinutes / 60.0;
  782. totalHours += segHours > 8.0 ? 8.0 : segHours;
  783. dayStart = dayEnd;
  784. }
  785. if (totalHours > 0) {
  786. _hoursCtrl.text = ((totalHours * 2).ceil() / 2.0).toStringAsFixed(1);
  787. }
  788. _autoCalcDays();
  789. }
  790. void _autoCalcDays() {
  791. final hours = double.tryParse(_hoursCtrl.text) ?? 0;
  792. if (hours <= 0) return;
  793. // 8小时工作制:天数 = 小时 ÷ 8
  794. final diffDays = hours / 8.0;
  795. _daysCtrl.text = ((diffDays * 2).ceil() / 2.0).toStringAsFixed(1);
  796. }
  797. /// 手动输入时失焦自动按 0.5 向上取整
  798. void _roundToHalf(TextEditingController controller) {
  799. final text = controller.text.trim();
  800. if (text.isEmpty) return;
  801. final value = double.tryParse(text);
  802. if (value == null || value <= 0) return;
  803. final rounded = (value * 2).ceil() / 2.0;
  804. final formatted = rounded.toStringAsFixed(1);
  805. if (controller.text != formatted) {
  806. controller.text = formatted;
  807. }
  808. }
  809. // ── 6. 加班时长 ──
  810. Widget _buildHoursInput(AppColorsExtension colors) {
  811. return _inputCard(
  812. label: _l10n.get('overtimeHours'),
  813. required: false,
  814. controller: _hoursCtrl,
  815. hintText: '>0',
  816. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  817. inputFormatters: [
  818. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?[05]?$')),
  819. ],
  820. focusNode: _hoursFocus,
  821. );
  822. }
  823. // ── 7. 加班天数 ──
  824. Widget _buildDaysInput(AppColorsExtension colors) {
  825. return _inputCard(
  826. label: _l10n.get('overtimeDays'),
  827. required: false,
  828. controller: _daysCtrl,
  829. hintText: '0',
  830. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  831. inputFormatters: [
  832. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?[05]?$')),
  833. ],
  834. focusNode: _daysFocus,
  835. );
  836. }
  837. // ── 8. 考勤周期 ──
  838. Widget _buildAttPeriodInput(AppColorsExtension colors) {
  839. return _datePickerCard(
  840. label: _l10n.get('attPeriod'),
  841. value: _attPeriod,
  842. hint: _l10n.get('autoFromDate'),
  843. colors: colors,
  844. onPick: (d) => setState(() => _attPeriod = d),
  845. onClear: _attPeriod.isNotEmpty
  846. ? () => setState(() => _attPeriod = '')
  847. : null,
  848. );
  849. }
  850. // ── 10. 事由 ──
  851. Widget _buildReasonInput(AppColorsExtension colors) {
  852. final tdTheme = TDTheme.of(context);
  853. return TDTextarea(
  854. controller: _reasonCtrl,
  855. focusNode: _reasonFocus,
  856. label: _l10n.get('overtimeDetailReason'),
  857. hintText: _l10n.get('enterReason'),
  858. maxLines: 3,
  859. minLines: 1,
  860. maxLength: 1000,
  861. indicator: true,
  862. decoration: BoxDecoration(
  863. color: tdTheme.bgColorContainer,
  864. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  865. border: Border.all(color: tdTheme.componentStrokeColor),
  866. ),
  867. onChanged: (_) => setState(() {}),
  868. );
  869. }
  870. // ── 10. 补偿类型 ──
  871. Widget _buildCompensationTypeSelector(AppColorsExtension colors) {
  872. final hasValue = _compensationType.isNotEmpty;
  873. final selLabel = hasValue ? _compensationTypeLabel(_compensationType) : _l10n.get('pleaseSelect');
  874. return _pickerCard(
  875. label: _l10n.get('compensationType'),
  876. required: false,
  877. hasValue: hasValue,
  878. currentLabel: selLabel,
  879. onTap: () => _showCompensationTypePicker(),
  880. );
  881. }
  882. void _showCompensationTypePicker() {
  883. FocusManager.instance.primaryFocus?.unfocus();
  884. final labels = _compensationTypeOptions
  885. .map((t) => _compensationTypeLabel(t))
  886. .toList();
  887. TDPicker.showMultiPicker(
  888. context,
  889. title: _l10n.get('selectCompensationType'),
  890. data: [labels],
  891. onConfirm: (selected) {
  892. if (selected.isNotEmpty) {
  893. final idx = selected.first is int ? selected.first as int : 0;
  894. if (idx >= 0 && idx < _compensationTypeOptions.length) {
  895. final newType = _compensationTypeOptions[idx];
  896. setState(() {
  897. _compensationType = newType;
  898. // 无补偿 → 清空;其他 → 默认取 JB_DAYS
  899. if (newType == 'NO_COMPENSATION') {
  900. _compCountCtrl.clear();
  901. } else if (_compCountCtrl.text.isEmpty && _daysCtrl.text.isNotEmpty) {
  902. _compCountCtrl.text = _daysCtrl.text;
  903. }
  904. });
  905. }
  906. }
  907. Navigator.of(context).pop();
  908. },
  909. );
  910. }
  911. String _compensationTypeLabel(String type) {
  912. switch (type) {
  913. case 'OVERTIME_PAY':
  914. return _l10n.get('overtimePay');
  915. case 'COMPENSATORY_LEAVE':
  916. return _l10n.get('compensatoryLeave');
  917. case 'NO_COMPENSATION':
  918. return _l10n.get('noCompensation');
  919. case 'OTHER':
  920. return _l10n.get('other');
  921. default:
  922. return type;
  923. }
  924. }
  925. // ── 11. 折算补偿次数 ──
  926. Widget _buildCompCountInput(AppColorsExtension colors) {
  927. // 无补偿时显示空
  928. if (_compensationType == 'NO_COMPENSATION') {
  929. final tdTheme = TDTheme.of(context);
  930. return GestureDetector(
  931. onTap: () {},
  932. child: Container(
  933. padding: const EdgeInsets.only(
  934. left: 16, right: 10, top: 12, bottom: 12,
  935. ),
  936. decoration: BoxDecoration(
  937. color: tdTheme.bgColorContainer,
  938. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  939. border: Border.all(color: tdTheme.componentStrokeColor),
  940. ),
  941. child: Row(
  942. children: [
  943. TDText(
  944. _l10n.get('compensationCount'),
  945. maxLines: 1,
  946. overflow: TextOverflow.visible,
  947. font: tdTheme.fontBodyLarge,
  948. fontWeight: FontWeight.w400,
  949. style: const TextStyle(letterSpacing: 0),
  950. ),
  951. const SizedBox(width: 12),
  952. Expanded(
  953. child: TDText(
  954. '',
  955. maxLines: 1,
  956. textAlign: TextAlign.end,
  957. font: tdTheme.fontBodyLarge,
  958. fontWeight: FontWeight.w400,
  959. textColor: tdTheme.textColorPlaceholder,
  960. ),
  961. ),
  962. ],
  963. ),
  964. ),
  965. );
  966. }
  967. return _inputCard(
  968. label: _l10n.get('compensationCount'),
  969. required: false,
  970. controller: _compCountCtrl,
  971. hintText: '0',
  972. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  973. inputFormatters: [
  974. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,1}$')),
  975. ],
  976. focusNode: _compCountFocus,
  977. );
  978. }
  979. // ── 12. 地点 ──
  980. Widget _buildAdrInput(AppColorsExtension colors) {
  981. return _inputCard(
  982. label: _l10n.get('adr'),
  983. required: false,
  984. controller: _adrCtrl,
  985. hintText: _l10n.get('enterAdr'),
  986. focusNode: _adrFocus,
  987. );
  988. }
  989. // ── 13. 备注 ──
  990. Widget _buildRemarkInput(AppColorsExtension colors) {
  991. final tdTheme = TDTheme.of(context);
  992. return TDTextarea(
  993. controller: _remarkCtrl,
  994. focusNode: _remarkFocus,
  995. label: _l10n.get('remark'),
  996. hintText: _l10n.get('enterRemark'),
  997. maxLines: 3,
  998. minLines: 1,
  999. maxLength: 1000,
  1000. indicator: true,
  1001. decoration: BoxDecoration(
  1002. color: tdTheme.bgColorContainer,
  1003. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  1004. border: Border.all(color: tdTheme.componentStrokeColor),
  1005. ),
  1006. onChanged: (_) => setState(() {}),
  1007. );
  1008. }
  1009. // ── 操作按钮 ──
  1010. Widget _buildActions() {
  1011. return Row(
  1012. children: [
  1013. Expanded(
  1014. child: TDButton(
  1015. text: _l10n.get('cancel'),
  1016. size: TDButtonSize.large,
  1017. type: TDButtonType.outline,
  1018. shape: TDButtonShape.rectangle,
  1019. theme: TDButtonTheme.defaultTheme,
  1020. onTap: () => Navigator.pop(context),
  1021. ),
  1022. ),
  1023. const SizedBox(width: 12),
  1024. Expanded(
  1025. child: TDButton(
  1026. text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'),
  1027. size: TDButtonSize.large,
  1028. type: TDButtonType.fill,
  1029. shape: TDButtonShape.rectangle,
  1030. theme: TDButtonTheme.primary,
  1031. onTap: _confirm,
  1032. ),
  1033. ),
  1034. ],
  1035. );
  1036. }
  1037. // ── 日期选择器卡片 ──
  1038. Widget _datePickerCard({
  1039. required String label,
  1040. required String value,
  1041. required String hint,
  1042. required AppColorsExtension colors,
  1043. required ValueChanged<String> onPick,
  1044. VoidCallback? onClear,
  1045. bool required = false,
  1046. bool showDayOfWeek = false,
  1047. }) {
  1048. final tdTheme = TDTheme.of(context);
  1049. final hasValue = value.isNotEmpty;
  1050. final dayOfWeek = showDayOfWeek && hasValue ? _dayOfWeekLabel(value) : null;
  1051. return GestureDetector(
  1052. onTap: () => _pickDateTime(label, onPick, currentValue: value),
  1053. child: Container(
  1054. padding: const EdgeInsets.only(
  1055. left: 16,
  1056. right: 10,
  1057. top: 12,
  1058. bottom: 12,
  1059. ),
  1060. decoration: BoxDecoration(
  1061. color: tdTheme.bgColorContainer,
  1062. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  1063. border: Border.all(color: tdTheme.componentStrokeColor),
  1064. ),
  1065. child: Row(
  1066. children: [
  1067. TDText(
  1068. label,
  1069. maxLines: 1,
  1070. overflow: TextOverflow.visible,
  1071. font: tdTheme.fontBodyLarge,
  1072. fontWeight: FontWeight.w400,
  1073. style: const TextStyle(letterSpacing: 0),
  1074. ),
  1075. if (required)
  1076. Padding(
  1077. padding: const EdgeInsets.only(left: 4),
  1078. child: TDText(
  1079. '*',
  1080. font: tdTheme.fontBodyLarge,
  1081. fontWeight: FontWeight.w400,
  1082. style: TextStyle(color: tdTheme.errorColor6),
  1083. ),
  1084. ),
  1085. const SizedBox(width: 12),
  1086. Expanded(
  1087. child: Row(
  1088. mainAxisAlignment: MainAxisAlignment.end,
  1089. mainAxisSize: MainAxisSize.max,
  1090. children: [
  1091. Flexible(
  1092. child: TDText(
  1093. value.isEmpty ? hint : value,
  1094. maxLines: 1,
  1095. overflow: TextOverflow.ellipsis,
  1096. font: tdTheme.fontBodyLarge,
  1097. fontWeight: FontWeight.w400,
  1098. textColor: value.isEmpty
  1099. ? tdTheme.textColorPlaceholder
  1100. : tdTheme.textColorPrimary,
  1101. textAlign: TextAlign.end,
  1102. ),
  1103. ),
  1104. if (dayOfWeek != null) ...[
  1105. const SizedBox(width: 6),
  1106. Container(
  1107. padding: const EdgeInsets.symmetric(
  1108. horizontal: 6,
  1109. vertical: 2,
  1110. ),
  1111. decoration: BoxDecoration(
  1112. color: tdTheme.brandColor1,
  1113. borderRadius: BorderRadius.circular(4),
  1114. ),
  1115. child: TDText(
  1116. dayOfWeek,
  1117. font: TDTheme.of(context).fontBodySmall,
  1118. fontWeight: FontWeight.w500,
  1119. textColor: tdTheme.brandColor7,
  1120. ),
  1121. ),
  1122. ],
  1123. const SizedBox(width: 4),
  1124. SizedBox(
  1125. width: 18,
  1126. height: 18,
  1127. child: hasValue
  1128. ? GestureDetector(
  1129. onTap: onClear,
  1130. child: Icon(
  1131. Icons.close,
  1132. size: 18,
  1133. color: tdTheme.textColorPlaceholder,
  1134. ),
  1135. )
  1136. : Icon(
  1137. Icons.chevron_right,
  1138. size: 18,
  1139. color: tdTheme.textColorPlaceholder,
  1140. ),
  1141. ),
  1142. ],
  1143. ),
  1144. ),
  1145. ],
  1146. ),
  1147. ),
  1148. );
  1149. }
  1150. void _pickDateTime(String label, ValueChanged<String> onPick,
  1151. {String currentValue = ''}) {
  1152. final l10n = _l10n;
  1153. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1154. final now = DateTime.now();
  1155. final isDateOnly =
  1156. label == _l10n.get('applyDate') || label == _l10n.get('attPeriod');
  1157. final isAttPeriod = label == _l10n.get('attPeriod');
  1158. final isStartTime = label == _l10n.get('startTime');
  1159. final isEndTime = label == _l10n.get('endTime');
  1160. // 根据当前值定位初始日期,无值时默认当月/当天
  1161. int initYear = now.year;
  1162. int initMonth = now.month;
  1163. int initDay = now.day;
  1164. int initHour = now.hour;
  1165. int initMinute = now.minute;
  1166. if (currentValue.isNotEmpty) {
  1167. if (isAttPeriod) {
  1168. // yyyy-MM
  1169. final parts = currentValue.split('-');
  1170. if (parts.length == 2) {
  1171. initYear = int.tryParse(parts[0]) ?? now.year;
  1172. initMonth = int.tryParse(parts[1]) ?? now.month;
  1173. initDay = 1;
  1174. }
  1175. } else {
  1176. // yyyy-MM-dd 或 yyyy-MM-dd HH:mm
  1177. final dt = DateTime.tryParse(currentValue);
  1178. if (dt != null) {
  1179. initYear = dt.year;
  1180. initMonth = dt.month;
  1181. initDay = dt.day;
  1182. initHour = dt.hour;
  1183. initMinute = dt.minute;
  1184. }
  1185. }
  1186. } else if (isStartTime) {
  1187. // 开始时间无当前值:工作日默认 18:00,周末默认 09:00
  1188. final date = _jbDate.isNotEmpty
  1189. ? DateTime.tryParse(_jbDate)
  1190. : null;
  1191. final weekday = date?.weekday ?? now.weekday;
  1192. initHour = (weekday <= DateTime.friday) ? 18 : 9;
  1193. initMinute = 0;
  1194. } else if (isEndTime) {
  1195. // 结束时间无当前值:工作日默认 19:00,周末默认 18:00
  1196. final date = _jbDate.isNotEmpty
  1197. ? DateTime.tryParse(_jbDate)
  1198. : null;
  1199. final weekday = date?.weekday ?? now.weekday;
  1200. initHour = (weekday <= DateTime.friday) ? 19 : 18;
  1201. initMinute = 0;
  1202. }
  1203. final useTime = !isDateOnly;
  1204. FocusManager.instance.primaryFocus?.unfocus();
  1205. TDPicker.showDatePicker(
  1206. context,
  1207. title: l10n.get('selectDate'),
  1208. backgroundColor: colors.bgCard,
  1209. useYear: true,
  1210. useMonth: true,
  1211. useDay: !isAttPeriod,
  1212. useHour: useTime,
  1213. useMinute: useTime,
  1214. useSecond: false,
  1215. useWeekDay: false,
  1216. dateStart: useTime ? const [2020, 1, 1, 0, 0] : const [2020, 1, 1],
  1217. dateEnd: useTime
  1218. ? [now.year + 1, 12, 31, 23, 59]
  1219. : [now.year + 1, 12, 31],
  1220. initialDate: useTime
  1221. ? [initYear, initMonth, initDay, initHour, initMinute]
  1222. : [initYear, initMonth, initDay],
  1223. onConfirm: (selected) {
  1224. final year = selected['year'];
  1225. final month = selected['month'].toString().padLeft(2, '0');
  1226. final day = selected['day'].toString().padLeft(2, '0');
  1227. final hour = selected['hour']?.toString().padLeft(2, '0') ?? '00';
  1228. final minute = selected['minute']?.toString().padLeft(2, '0') ?? '00';
  1229. if (isAttPeriod) {
  1230. onPick('$year-$month');
  1231. } else if (isDateOnly) {
  1232. onPick('$year-$month-$day');
  1233. } else {
  1234. onPick('$year-$month-$day $hour:$minute');
  1235. }
  1236. Navigator.of(context).pop();
  1237. },
  1238. );
  1239. }
  1240. // ── 通用输入卡片 ──
  1241. Widget _inputCard({
  1242. required String label,
  1243. required bool required,
  1244. required TextEditingController controller,
  1245. required String hintText,
  1246. TextInputType? keyboardType,
  1247. List<TextInputFormatter>? inputFormatters,
  1248. FocusNode? focusNode,
  1249. }) {
  1250. final tdTheme = TDTheme.of(context);
  1251. final hasValue = controller.text.isNotEmpty;
  1252. return Container(
  1253. padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
  1254. decoration: BoxDecoration(
  1255. color: tdTheme.bgColorContainer,
  1256. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  1257. border: Border.all(color: tdTheme.componentStrokeColor),
  1258. ),
  1259. child: Row(
  1260. children: [
  1261. TDText(
  1262. label,
  1263. maxLines: 1,
  1264. overflow: TextOverflow.visible,
  1265. font: tdTheme.fontBodyLarge,
  1266. fontWeight: FontWeight.w400,
  1267. style: const TextStyle(letterSpacing: 0),
  1268. ),
  1269. if (required)
  1270. Padding(
  1271. padding: const EdgeInsets.only(left: 4),
  1272. child: TDText(
  1273. '*',
  1274. font: tdTheme.fontBodyLarge,
  1275. fontWeight: FontWeight.w400,
  1276. style: TextStyle(color: tdTheme.errorColor6),
  1277. ),
  1278. ),
  1279. const SizedBox(width: 12),
  1280. Expanded(
  1281. child: Row(
  1282. mainAxisAlignment: MainAxisAlignment.end,
  1283. mainAxisSize: MainAxisSize.max,
  1284. children: [
  1285. Flexible(
  1286. child: TextField(
  1287. controller: controller,
  1288. focusNode: focusNode,
  1289. textAlign: TextAlign.end,
  1290. keyboardType: keyboardType,
  1291. inputFormatters: inputFormatters,
  1292. style: TextStyle(
  1293. fontSize: 16,
  1294. color: tdTheme.textColorPrimary,
  1295. ),
  1296. decoration: InputDecoration(
  1297. hintText: hintText,
  1298. hintStyle: TextStyle(
  1299. fontSize: 16,
  1300. color: tdTheme.textColorPlaceholder,
  1301. ),
  1302. border: InputBorder.none,
  1303. isDense: true,
  1304. contentPadding: EdgeInsets.zero,
  1305. ),
  1306. onChanged: (_) => setState(() {}),
  1307. ),
  1308. ),
  1309. const SizedBox(width: 4),
  1310. SizedBox(
  1311. width: 18,
  1312. height: 18,
  1313. child: hasValue
  1314. ? GestureDetector(
  1315. onTap: () {
  1316. controller.clear();
  1317. setState(() {});
  1318. },
  1319. child: Icon(
  1320. Icons.close,
  1321. size: 18,
  1322. color: tdTheme.textColorPlaceholder,
  1323. ),
  1324. )
  1325. : null,
  1326. ),
  1327. ],
  1328. ),
  1329. ),
  1330. ],
  1331. ),
  1332. );
  1333. }
  1334. }