overtime_apply_detail_dialog.dart 43 KB

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