overtime_create_page.dart 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/utils/date_utils.dart' as du;
  6. import '../../shared/widgets/action_bar.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../core/i18n/app_localizations.dart';
  10. import 'overtime_create_controller.dart';
  11. import '../../core/theme/app_colors.dart';
  12. import '../../core/theme/app_colors_extension.dart';
  13. class OvertimeCreatePage extends ConsumerStatefulWidget {
  14. final String? editId;
  15. const OvertimeCreatePage({super.key, this.editId});
  16. @override
  17. ConsumerState<OvertimeCreatePage> createState() => _OvertimeCreatePageState();
  18. }
  19. class _OvertimeCreatePageState extends ConsumerState<OvertimeCreatePage> {
  20. final _reasonController = TextEditingController();
  21. final _reasonFocus = FocusNode();
  22. final _scrollCtrl = ScrollController();
  23. static const _typeKeys = ['workday', 'weekend', 'holiday'];
  24. static const _compensationKeys = ['overtime_pay', 'comp_leave'];
  25. @override
  26. void initState() {
  27. super.initState();
  28. final state = ref.read(overtimeCreateProvider(widget.editId));
  29. _reasonController.text = state.overtime.reason;
  30. _reasonController.addListener(_onReasonChanged);
  31. }
  32. @override
  33. void dispose() {
  34. _reasonController.removeListener(_onReasonChanged);
  35. _reasonController.dispose();
  36. _reasonFocus.dispose();
  37. _scrollCtrl.dispose();
  38. super.dispose();
  39. }
  40. void _onReasonChanged() {
  41. final ctrl = ref.read(overtimeCreateProvider(widget.editId).notifier);
  42. ctrl.updateReason(_reasonController.text);
  43. }
  44. @override
  45. Widget build(BuildContext context) {
  46. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  47. final ctrl = ref.watch(overtimeCreateProvider(widget.editId).notifier);
  48. final state = ref.watch(overtimeCreateProvider(widget.editId));
  49. final l10n = AppLocalizations.of(context);
  50. return PopScope(
  51. canPop: false,
  52. onPopInvokedWithResult: (didPop, _) {
  53. if (!didPop) {
  54. if (_hasUnsaved()) {
  55. _showConfirmDialog(
  56. l10n.get('confirmExit'),
  57. l10n.get('unsavedContentWarning'),
  58. l10n.get('continueEditing'),
  59. l10n.get('discardAndExit'),
  60. () => context.pop(),
  61. );
  62. } else {
  63. context.pop();
  64. }
  65. }
  66. },
  67. child: Column(
  68. children: [
  69. Expanded(
  70. child: GestureDetector(
  71. onTap: () => FocusScope.of(context).unfocus(),
  72. child: SingleChildScrollView(
  73. controller: _scrollCtrl,
  74. padding: const EdgeInsets.all(16),
  75. child: Column(
  76. children: [
  77. FormSection(
  78. title: l10n.get('overtimeInfo'),
  79. leadingIcon: Icons.more_time_outlined,
  80. children: [
  81. FormFieldRow(
  82. label: l10n.get('overtimeType'),
  83. value: _typeLabel(state.overtime.otType),
  84. onTap: () => _showPicker(
  85. _typeKeys,
  86. _typeLabel,
  87. ctrl.updateType,
  88. title: l10n.get('selectOvertimeType'),
  89. ),
  90. ),
  91. const SizedBox(height: 16),
  92. FormFieldRow(
  93. label: l10n.get('compensationMethod'),
  94. value: _compensationLabel(
  95. state.overtime.compensationType),
  96. onTap: () => _showPicker(
  97. _compensationKeys,
  98. _compensationLabel,
  99. ctrl.updateCompensation,
  100. title: l10n.get('selectCompensationMethod'),
  101. ),
  102. ),
  103. const SizedBox(height: 16),
  104. FormFieldRow(
  105. label: l10n.get('startTime'),
  106. value:
  107. du.DateUtils.formatDateTime(state.overtime.startTime),
  108. onTap: () => _pickDateTime(
  109. ctrl.updateStartTime,
  110. state.overtime.startTime,
  111. ),
  112. ),
  113. const SizedBox(height: 16),
  114. FormFieldRow(
  115. label: l10n.get('endTime'),
  116. value:
  117. du.DateUtils.formatDateTime(state.overtime.endTime),
  118. onTap: () => _pickDateTime(
  119. ctrl.updateEndTime,
  120. state.overtime.endTime,
  121. ),
  122. ),
  123. const SizedBox(height: 16),
  124. FormFieldRow(
  125. label: l10n.get('netOvertimeHours'),
  126. value:
  127. '${state.overtime.otHours.toStringAsFixed(1)} ${l10n.get('hours')}',
  128. readOnly: true,
  129. showArrow: false,
  130. ),
  131. const SizedBox(height: 16),
  132. _label(l10n.get('overtimeReason')),
  133. const SizedBox(height: 8),
  134. TDTextarea(
  135. controller: _reasonController,
  136. focusNode: _reasonFocus,
  137. hintText: l10n.get('enterOvertimeReason'),
  138. maxLines: 4,
  139. minLines: 1,
  140. maxLength: 500,
  141. indicator: true,
  142. padding: EdgeInsets.zero,
  143. bordered: true,
  144. backgroundColor: colors.bgPage,
  145. ),
  146. ],
  147. ),
  148. const SizedBox(height: 80),
  149. ],
  150. ),
  151. ),
  152. ),
  153. ),
  154. ActionBar(
  155. showLeft: false,
  156. centerLabel: l10n.get('saveDraftShort'),
  157. rightLabel: l10n.get('submitApproval'),
  158. onCenterTap: state.isSubmitting
  159. ? null
  160. : () async {
  161. await ctrl.saveDraft();
  162. if (context.mounted) context.pop();
  163. },
  164. onRightTap: state.isSubmitting
  165. ? null
  166. : () async {
  167. final ok = await ctrl.submit();
  168. if (context.mounted && ok) context.pop();
  169. },
  170. ),
  171. ],
  172. ),
  173. );
  174. }
  175. bool _hasUnsaved() => _reasonController.text.isNotEmpty;
  176. void _unfocus() => FocusScope.of(context).unfocus();
  177. void _showConfirmDialog(
  178. String title,
  179. String content,
  180. String leftText,
  181. String rightText,
  182. VoidCallback onConfirm,
  183. ) {
  184. _unfocus();
  185. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  186. showDialog(
  187. context: context,
  188. builder: (ctx) => TDAlertDialog(
  189. title: title,
  190. content: content,
  191. buttonStyle: TDDialogButtonStyle.text,
  192. leftBtn: TDDialogButtonOptions(
  193. title: leftText,
  194. titleColor: colors.primary,
  195. action: () => Navigator.pop(ctx),
  196. ),
  197. rightBtn: TDDialogButtonOptions(
  198. title: rightText,
  199. titleColor: colors.danger,
  200. action: () {
  201. Navigator.pop(ctx);
  202. onConfirm();
  203. },
  204. ),
  205. ),
  206. );
  207. }
  208. String _typeLabel(String key) {
  209. final l10n = AppLocalizations.of(context);
  210. switch (key) {
  211. case 'workday':
  212. return l10n.get('overtimeWorkday');
  213. case 'weekend':
  214. return l10n.get('overtimeWeekend');
  215. case 'holiday':
  216. return l10n.get('overtimeHoliday');
  217. default:
  218. return key;
  219. }
  220. }
  221. String _compensationLabel(String key) {
  222. final l10n = AppLocalizations.of(context);
  223. switch (key) {
  224. case 'overtime_pay':
  225. return l10n.get('overtimePay');
  226. case 'comp_leave':
  227. return l10n.get('compLeave');
  228. default:
  229. return key;
  230. }
  231. }
  232. Widget _label(String t, {bool required = false}) {
  233. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  234. return Text.rich(
  235. TextSpan(
  236. children: [
  237. TextSpan(
  238. text: t,
  239. style: TextStyle(
  240. fontSize: AppFontSizes.subtitle,
  241. color: colors.textSecondary,
  242. ),
  243. ),
  244. if (required)
  245. TextSpan(
  246. text: ' *',
  247. style: TextStyle(
  248. fontSize: AppFontSizes.subtitle,
  249. color: colors.danger,
  250. ),
  251. ),
  252. ],
  253. ),
  254. );
  255. }
  256. void _showPicker(
  257. List<String> optionKeys,
  258. String Function(String) labelFn,
  259. void Function(String) onPick, {
  260. String title = '',
  261. }) {
  262. final l10n = AppLocalizations.of(context);
  263. if (title.isEmpty) title = l10n.get('pleaseSelect');
  264. final displayValues = optionKeys.map(labelFn).toList();
  265. TDPicker.showMultiPicker(
  266. context,
  267. title: title,
  268. data: [displayValues],
  269. onConfirm: (selected) {
  270. final idx = displayValues.indexOf(selected.first);
  271. if (idx >= 0) onPick(optionKeys[idx]);
  272. },
  273. );
  274. }
  275. void _pickDateTime(void Function(DateTime) onPicked, DateTime initial) {
  276. final l10n = AppLocalizations.of(context);
  277. TDPicker.showDatePicker(
  278. context,
  279. title: l10n.get('selectDateTime'),
  280. useYear: true,
  281. useMonth: true,
  282. useDay: true,
  283. useHour: true,
  284. useMinute: true,
  285. initialDate: [
  286. initial.year,
  287. initial.month,
  288. initial.day,
  289. initial.hour,
  290. initial.minute,
  291. ],
  292. onConfirm: (selected) {
  293. onPicked(
  294. DateTime(
  295. selected['year']!,
  296. selected['month']!,
  297. selected['day']!,
  298. selected['hour']!,
  299. selected['minute']!,
  300. ),
  301. );
  302. },
  303. );
  304. }
  305. }