outing_log_create_page.dart 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  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 '../../shared/widgets/form_section.dart';
  6. import '../../shared/widgets/form_field_row.dart';
  7. import '../../shared/widgets/action_bar.dart';
  8. import '../../core/i18n/app_localizations.dart';
  9. import '../../core/theme/app_colors_extension.dart';
  10. import '../../core/theme/app_colors.dart';
  11. class OutingLogCreatePage extends ConsumerStatefulWidget {
  12. const OutingLogCreatePage({super.key});
  13. @override
  14. ConsumerState<OutingLogCreatePage> createState() =>
  15. _OutingLogCreatePageState();
  16. }
  17. class _OutingLogCreatePageState extends ConsumerState<OutingLogCreatePage> {
  18. final _customerCtrl = TextEditingController();
  19. final _summaryCtrl = TextEditingController();
  20. final _planCtrl = TextEditingController();
  21. final _summaryFocus = FocusNode();
  22. final _scrollCtrl = ScrollController();
  23. // GPS 模拟
  24. String _gpsAddress = '深圳市南山区科技园南路88号';
  25. final double _gpsLat = 22.5431;
  26. final double _gpsLng = 113.9532;
  27. double _gpsAccuracy = 15.0;
  28. bool _gpsFailed = false;
  29. // 客户联想
  30. final List<String> _mockCustomers = [
  31. '华软科技',
  32. '云创数据',
  33. '数据引力',
  34. '天诚科技',
  35. '博思软件',
  36. '智云科技',
  37. '恒通信息',
  38. '创新无限',
  39. ];
  40. String? _selectedCustomer;
  41. // 客户联系人
  42. final Map<String, List<Map<String, String>>> _mockContacts = {
  43. '华软科技': [
  44. {'name': '赵经理', 'phone': '13800138001', 'position': 'IT经理'},
  45. {'name': '李主管', 'phone': '13800138002', 'position': '采购主管'},
  46. ],
  47. '云创数据': [
  48. {'name': '陈经理', 'phone': '13800138003', 'position': '技术总监'},
  49. ],
  50. '数据引力': [
  51. {'name': '孙总', 'phone': '13800138004', 'position': '总经理'},
  52. ],
  53. '天诚科技': [
  54. {'name': '周主任', 'phone': '13800138005', 'position': '办公室主任'},
  55. ],
  56. };
  57. Map<String, String>? _selectedContact;
  58. // 照片
  59. final List<String> _photos = [];
  60. static const int _maxPhotos = 9;
  61. @override
  62. void initState() {
  63. super.initState();
  64. _summaryFocus.addListener(() => _ensureVisible(_summaryFocus));
  65. }
  66. void _ensureVisible(FocusNode node) {
  67. if (!node.hasFocus) return;
  68. WidgetsBinding.instance.addPostFrameCallback((_) {
  69. if (node.hasFocus && _scrollCtrl.hasClients) {
  70. final ctx = node.context;
  71. if (ctx != null) {
  72. Scrollable.ensureVisible(
  73. ctx,
  74. alignment: 0.3,
  75. duration: const Duration(milliseconds: 300),
  76. );
  77. }
  78. }
  79. });
  80. }
  81. @override
  82. void dispose() {
  83. _customerCtrl.dispose();
  84. _summaryCtrl.dispose();
  85. _planCtrl.dispose();
  86. _summaryFocus.dispose();
  87. _scrollCtrl.dispose();
  88. super.dispose();
  89. }
  90. bool _hasUnsaved() =>
  91. _customerCtrl.text.isNotEmpty ||
  92. _summaryCtrl.text.isNotEmpty ||
  93. _planCtrl.text.isNotEmpty ||
  94. _photos.isNotEmpty;
  95. void _unfocus() => FocusScope.of(context).unfocus();
  96. void _showConfirmDialog(
  97. String title,
  98. String content,
  99. String leftText,
  100. String rightText,
  101. VoidCallback onConfirm,
  102. ) {
  103. _unfocus();
  104. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  105. showDialog(
  106. context: context,
  107. builder: (ctx) => TDAlertDialog(
  108. title: title,
  109. content: content,
  110. buttonStyle: TDDialogButtonStyle.text,
  111. leftBtn: TDDialogButtonOptions(
  112. title: leftText,
  113. titleColor: colors.primary,
  114. action: () => Navigator.pop(ctx),
  115. ),
  116. rightBtn: TDDialogButtonOptions(
  117. title: rightText,
  118. titleColor: colors.danger,
  119. action: () {
  120. Navigator.pop(ctx);
  121. onConfirm();
  122. },
  123. ),
  124. ),
  125. );
  126. }
  127. Future<void> _pickCustomer() async {
  128. final l10n = AppLocalizations.of(context);
  129. final searchCtrl = TextEditingController();
  130. final selected = await showDialog<String>(
  131. context: context,
  132. builder: (ctx) {
  133. String filter = '';
  134. return StatefulBuilder(
  135. builder: (context, setDialogState) {
  136. final query = filter;
  137. final suggestions = query.isEmpty
  138. ? _mockCustomers
  139. : _mockCustomers.where((c) => c.contains(query)).toList();
  140. return AlertDialog(
  141. title: Text(l10n.get('searchCustomer')),
  142. content: SizedBox(
  143. width: double.maxFinite,
  144. child: Column(
  145. mainAxisSize: MainAxisSize.min,
  146. children: [
  147. TDInput(
  148. controller: searchCtrl,
  149. hintText: l10n.get('searchCustomer'),
  150. onChanged: (v) {
  151. filter = v;
  152. setDialogState(() {});
  153. },
  154. ),
  155. const SizedBox(height: 8),
  156. if (suggestions.isEmpty)
  157. Padding(
  158. padding: const EdgeInsets.all(16),
  159. child: Text(
  160. l10n.get('noData'),
  161. style: TextStyle(
  162. fontSize: AppFontSizes.body,
  163. color: Theme.of(context)
  164. .extension<AppColorsExtension>()!
  165. .textPlaceholder,
  166. ),
  167. ),
  168. )
  169. else
  170. SizedBox(
  171. height: 300,
  172. child: ListView.separated(
  173. shrinkWrap: true,
  174. itemCount: suggestions.length,
  175. separatorBuilder: (_, _) => const Divider(height: 1),
  176. itemBuilder: (_, i) => ListTile(
  177. dense: true,
  178. title: Text(suggestions[i]),
  179. onTap: () => Navigator.pop(ctx, suggestions[i]),
  180. ),
  181. ),
  182. ),
  183. ],
  184. ),
  185. ),
  186. actions: [
  187. TextButton(
  188. onPressed: () => Navigator.pop(ctx),
  189. child: Text(l10n.get('cancel')),
  190. ),
  191. ],
  192. );
  193. },
  194. );
  195. },
  196. );
  197. searchCtrl.dispose();
  198. if (selected != null && mounted) {
  199. setState(() {
  200. _selectedCustomer = selected;
  201. _customerCtrl.text = selected;
  202. _selectedContact = null;
  203. });
  204. }
  205. }
  206. Future<void> _pickContact() async {
  207. final l10n = AppLocalizations.of(context);
  208. if (_selectedCustomer == null) {
  209. TDToast.showText(l10n.get('selectCustomerFirst'), context: context);
  210. return;
  211. }
  212. final contacts = _mockContacts[_selectedCustomer];
  213. if (contacts == null || contacts.isEmpty) {
  214. TDToast.showText(l10n.get('noContact'), context: context);
  215. return;
  216. }
  217. final result = await showDialog<Map<String, String>>(
  218. context: context,
  219. builder: (ctx) => TDAlertDialog.vertical(
  220. title: l10n.get('selectContact'),
  221. buttons: contacts
  222. .map(
  223. (c) => TDDialogButtonOptions(
  224. title: '${c['name']} ${c['position']} ${c['phone']}',
  225. action: () => Navigator.pop(ctx, c),
  226. ),
  227. )
  228. .toList(),
  229. ),
  230. );
  231. if (result != null && mounted) {
  232. setState(() => _selectedContact = result);
  233. }
  234. }
  235. Future<void> _takePhoto() async {
  236. final l10n = AppLocalizations.of(context);
  237. if (_photos.length >= _maxPhotos) {
  238. TDToast.showText(l10n.get('maxPhotoCount'), context: context);
  239. return;
  240. }
  241. final idx = _photos.length + 1;
  242. setState(() {
  243. _photos.add('photo_placeholder_$idx');
  244. });
  245. if (context.mounted) {
  246. TDToast.showText(
  247. l10n.getString(
  248. 'mockPhotoTaken',
  249. args: {
  250. 'idx': '$idx',
  251. 'time': DateTime.now().toString().substring(0, 19),
  252. 'lat': '$_gpsLat°N',
  253. 'lng': '$_gpsLng°E',
  254. },
  255. ),
  256. context: context,
  257. );
  258. }
  259. }
  260. void _removePhoto(int index) {
  261. setState(() => _photos.removeAt(index));
  262. }
  263. Future<void> _simulateGps() async {
  264. final l10n = AppLocalizations.of(context);
  265. setState(() {
  266. _gpsFailed = false;
  267. _gpsAddress = '深圳市南山区科技园南路88号';
  268. _gpsAccuracy = 15.0;
  269. });
  270. TDToast.showText(l10n.get('gpsSuccess'), context: context);
  271. }
  272. Future<void> _saveDraft() async {
  273. final l10n = AppLocalizations.of(context);
  274. if (context.mounted) {
  275. TDToast.showText(l10n.get('draftSavedToast'), context: context);
  276. }
  277. }
  278. Future<void> _submit() async {
  279. final l10n = AppLocalizations.of(context);
  280. if (_gpsFailed) {
  281. TDToast.showText(l10n.get('gpsPermission'), context: context);
  282. return;
  283. }
  284. if (_gpsAddress.isEmpty) {
  285. TDToast.showText(l10n.get('gpsLocatingWait'), context: context);
  286. return;
  287. }
  288. if (_photos.isEmpty) {
  289. TDToast.showText(l10n.get('requiredPhotos'), context: context);
  290. return;
  291. }
  292. if (_summaryCtrl.text.trim().isEmpty) {
  293. TDToast.showText(l10n.get('requiredSummary'), context: context);
  294. return;
  295. }
  296. if (context.mounted) {
  297. TDToast.showText(l10n.get('outingLogSubmitted'), context: context);
  298. context.pop();
  299. }
  300. }
  301. // ─────────────────────────────────────────────
  302. // Build
  303. // ─────────────────────────────────────────────
  304. @override
  305. Widget build(BuildContext context) {
  306. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  307. final l10n = AppLocalizations.of(context);
  308. return PopScope(
  309. canPop: false,
  310. onPopInvokedWithResult: (didPop, _) {
  311. if (!didPop) {
  312. if (_hasUnsaved()) {
  313. _showConfirmDialog(
  314. l10n.get('confirmExit'),
  315. l10n.get('unsavedContentWarning'),
  316. l10n.get('continueEditing'),
  317. l10n.get('discardAndExit'),
  318. () => context.pop(),
  319. );
  320. } else {
  321. context.pop();
  322. }
  323. }
  324. },
  325. child: Column(
  326. children: [
  327. Expanded(
  328. child: GestureDetector(
  329. onTap: () => FocusScope.of(context).unfocus(),
  330. child: SingleChildScrollView(
  331. controller: _scrollCtrl,
  332. padding: const EdgeInsets.all(16),
  333. child: Column(
  334. children: [
  335. _buildGpsSection(),
  336. const SizedBox(height: 16),
  337. _buildCustomerSection(l10n, colors),
  338. const SizedBox(height: 16),
  339. _buildSummarySection(l10n, colors),
  340. const SizedBox(height: 16),
  341. _buildPlanSection(l10n, colors),
  342. const SizedBox(height: 16),
  343. _buildPhotosSection(l10n, colors),
  344. const SizedBox(height: 80),
  345. ],
  346. ),
  347. ),
  348. ),
  349. ),
  350. ActionBar(
  351. centerLabel: l10n.get('saveDraft'),
  352. rightLabel: l10n.get('submit'),
  353. showLeft: false,
  354. onCenterTap: _saveDraft,
  355. onRightTap: _submit,
  356. ),
  357. ],
  358. ),
  359. );
  360. }
  361. // ═══ GPS 定位 ═══
  362. Widget _buildGpsSection() {
  363. final l10n = AppLocalizations.of(context);
  364. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  365. return FormSection(
  366. title: 'GPS定位',
  367. leadingIcon: Icons.location_on_outlined,
  368. children: [
  369. if (_gpsFailed)
  370. Row(
  371. children: [
  372. Icon(Icons.location_off, size: 22, color: colors.statusGray),
  373. const SizedBox(width: 10),
  374. Expanded(
  375. child: Column(
  376. crossAxisAlignment: CrossAxisAlignment.start,
  377. children: [
  378. Text(
  379. l10n.get('gpsFailed'),
  380. style: TextStyle(
  381. fontSize: AppFontSizes.body,
  382. color: colors.textPrimary,
  383. ),
  384. ),
  385. const SizedBox(height: 4),
  386. Text(
  387. l10n.get('gpsFailedHint'),
  388. style: TextStyle(
  389. fontSize: AppFontSizes.caption,
  390. color: colors.textSecondary,
  391. ),
  392. ),
  393. ],
  394. ),
  395. ),
  396. GestureDetector(
  397. onTap: _simulateGps,
  398. child: Container(
  399. padding: const EdgeInsets.symmetric(
  400. horizontal: 12,
  401. vertical: 6,
  402. ),
  403. decoration: BoxDecoration(
  404. color: colors.primaryLight,
  405. borderRadius: BorderRadius.circular(4),
  406. ),
  407. child: Text(
  408. l10n.get('retry'),
  409. style: TextStyle(
  410. fontSize: AppFontSizes.caption,
  411. color: colors.primary,
  412. ),
  413. ),
  414. ),
  415. ),
  416. ],
  417. )
  418. else
  419. _buildGpsSuccess(colors),
  420. ],
  421. );
  422. }
  423. Widget _buildGpsSuccess(AppColorsExtension colors) {
  424. final isWarning = _gpsAccuracy > 100;
  425. return Row(
  426. crossAxisAlignment: CrossAxisAlignment.start,
  427. children: [
  428. Icon(
  429. Icons.shield_outlined,
  430. size: 22,
  431. color: isWarning ? colors.warning : colors.success,
  432. ),
  433. const SizedBox(width: 10),
  434. Expanded(
  435. child: Column(
  436. crossAxisAlignment: CrossAxisAlignment.start,
  437. children: [
  438. Text(
  439. _gpsAddress,
  440. style: TextStyle(
  441. fontSize: AppFontSizes.body,
  442. color: colors.textPrimary,
  443. ),
  444. ),
  445. const SizedBox(height: 4),
  446. Row(
  447. children: [
  448. Text(
  449. '${_gpsLat.toStringAsFixed(4)}°N, ${_gpsLng.toStringAsFixed(4)}°E · 精度 ${_gpsAccuracy.toStringAsFixed(0)}m',
  450. style: TextStyle(
  451. fontSize: AppFontSizes.caption,
  452. color: isWarning ? colors.warning : colors.textSecondary,
  453. ),
  454. ),
  455. if (isWarning) ...[
  456. const SizedBox(width: 6),
  457. Icon(
  458. Icons.warning_amber_rounded,
  459. size: 14,
  460. color: colors.warning,
  461. ),
  462. ],
  463. ],
  464. ),
  465. ],
  466. ),
  467. ),
  468. ],
  469. );
  470. }
  471. // ═══ 客户信息 ═══
  472. Widget _buildCustomerSection(AppLocalizations l10n, AppColorsExtension colors) {
  473. return FormSection(
  474. title: l10n.get('customerInfo'),
  475. leadingIcon: Icons.business_outlined,
  476. children: [
  477. FormFieldRow(
  478. label: l10n.get('customerName'),
  479. value: _selectedCustomer,
  480. hint: l10n.get('searchCustomer'),
  481. onTap: _pickCustomer,
  482. ),
  483. const SizedBox(height: 16),
  484. FormFieldRow(
  485. label: l10n.get('selectContact'),
  486. value: _selectedContact != null
  487. ? '${_selectedContact!['name']} ${_selectedContact!['phone']}'
  488. : null,
  489. hint: l10n.get('selectContactHint'),
  490. onTap: _pickContact,
  491. ),
  492. ],
  493. );
  494. }
  495. // ═══ 工作总结 ═══
  496. Widget _buildSummarySection(AppLocalizations l10n, AppColorsExtension colors) {
  497. return FormSection(
  498. title: l10n.get('workSummary'),
  499. leadingIcon: Icons.description_outlined,
  500. children: [
  501. _label(l10n.get('workSummary'), required: true),
  502. const SizedBox(height: 8),
  503. TDTextarea(
  504. controller: _summaryCtrl,
  505. focusNode: _summaryFocus,
  506. hintText: l10n.get('workSummaryRequiredHint'),
  507. maxLines: 5,
  508. minLines: 1,
  509. maxLength: 500,
  510. indicator: true,
  511. padding: EdgeInsets.zero,
  512. bordered: true,
  513. backgroundColor: colors.bgPage,
  514. ),
  515. ],
  516. );
  517. }
  518. // ═══ 跟进计划 ═══
  519. Widget _buildPlanSection(AppLocalizations l10n, AppColorsExtension colors) {
  520. return FormSection(
  521. title: l10n.get('followUp'),
  522. leadingIcon: Icons.event_note_outlined,
  523. children: [
  524. _label(l10n.get('followUp')),
  525. const SizedBox(height: 8),
  526. TDInput(
  527. controller: _planCtrl,
  528. hintText: l10n.get('followUpOptional'),
  529. decoration: BoxDecoration(
  530. color: colors.bgPage,
  531. borderRadius: BorderRadius.circular(4),
  532. border: Border.all(color: colors.border),
  533. ),
  534. ),
  535. ],
  536. );
  537. }
  538. // ═══ 现场拍照 ═══
  539. Widget _buildPhotosSection(AppLocalizations l10n, AppColorsExtension colors) {
  540. return FormSection(
  541. title: l10n.get('sitePhotos'),
  542. leadingIcon: Icons.camera_alt_outlined,
  543. showAction: _photos.length < _maxPhotos,
  544. actionText: _photos.length >= _maxPhotos
  545. ? l10n.get('limitReached')
  546. : l10n.get('takePhoto'),
  547. onActionTap: _photos.length >= _maxPhotos ? null : _takePhoto,
  548. children: [
  549. if (_photos.isEmpty)
  550. GestureDetector(
  551. onTap: _takePhoto,
  552. child: Container(
  553. height: 100,
  554. decoration: BoxDecoration(
  555. color: colors.bgPage,
  556. borderRadius: BorderRadius.circular(4),
  557. border: Border.all(color: colors.border, width: 1),
  558. ),
  559. child: Center(
  560. child: Column(
  561. mainAxisAlignment: MainAxisAlignment.center,
  562. children: [
  563. Icon(
  564. Icons.camera_alt_outlined,
  565. size: 32,
  566. color: colors.primary,
  567. ),
  568. const SizedBox(height: 4),
  569. Text(
  570. l10n.get('tapToTakePhoto'),
  571. style: TextStyle(
  572. fontSize: AppFontSizes.caption,
  573. color: colors.textPlaceholder,
  574. ),
  575. ),
  576. ],
  577. ),
  578. ),
  579. ),
  580. )
  581. else
  582. Wrap(
  583. spacing: 8,
  584. runSpacing: 8,
  585. children: [
  586. ..._photos.asMap().entries.map(
  587. (entry) => _buildPhotoThumbnail(entry.key, entry.value),
  588. ),
  589. if (_photos.length < _maxPhotos)
  590. GestureDetector(
  591. onTap: _takePhoto,
  592. child: Container(
  593. width: 80,
  594. height: 80,
  595. decoration: BoxDecoration(
  596. color: colors.bgPage,
  597. borderRadius: BorderRadius.circular(4),
  598. border: Border.all(color: colors.border),
  599. ),
  600. child: Icon(
  601. Icons.add,
  602. size: 28,
  603. color: colors.primary,
  604. ),
  605. ),
  606. ),
  607. ],
  608. ),
  609. const SizedBox(height: 8),
  610. Container(
  611. padding: const EdgeInsets.all(8),
  612. decoration: BoxDecoration(
  613. color: colors.primaryLight,
  614. borderRadius: BorderRadius.circular(4),
  615. ),
  616. child: Row(
  617. children: [
  618. Icon(
  619. Icons.info_outline,
  620. size: 14,
  621. color: colors.primary,
  622. ),
  623. const SizedBox(width: 6),
  624. Expanded(
  625. child: Text(
  626. l10n.getString(
  627. 'watermarkHintDynamic',
  628. args: {
  629. 'lat': _gpsLat.toStringAsFixed(4),
  630. 'lng': _gpsLng.toStringAsFixed(4),
  631. },
  632. ),
  633. style: TextStyle(
  634. fontSize: AppFontSizes.caption,
  635. color: colors.textSecondary,
  636. ),
  637. ),
  638. ),
  639. ],
  640. ),
  641. ),
  642. ],
  643. );
  644. }
  645. Widget _buildPhotoThumbnail(int index, String photo) {
  646. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  647. return Stack(
  648. children: [
  649. Container(
  650. width: 80,
  651. height: 80,
  652. decoration: BoxDecoration(
  653. color: colors.infoBorder,
  654. borderRadius: BorderRadius.circular(4),
  655. ),
  656. child: Center(
  657. child: Icon(Icons.image_outlined, size: 32, color: colors.primary),
  658. ),
  659. ),
  660. Positioned(
  661. top: -4,
  662. right: -4,
  663. child: GestureDetector(
  664. onTap: () => _removePhoto(index),
  665. child: Container(
  666. padding: const EdgeInsets.all(2),
  667. decoration: BoxDecoration(
  668. color: colors.danger,
  669. shape: BoxShape.circle,
  670. ),
  671. child: const Icon(Icons.close, size: 14, color: Colors.white),
  672. ),
  673. ),
  674. ),
  675. Positioned(
  676. bottom: 2,
  677. left: 2,
  678. right: 2,
  679. child: Container(
  680. padding: const EdgeInsets.symmetric(horizontal: 2),
  681. color: Colors.black54,
  682. child: Text(
  683. '${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')} ${_gpsLat.toStringAsFixed(2)},${_gpsLng.toStringAsFixed(2)}',
  684. style: const TextStyle(fontSize: 8, color: Colors.white),
  685. maxLines: 1,
  686. overflow: TextOverflow.ellipsis,
  687. ),
  688. ),
  689. ),
  690. ],
  691. );
  692. }
  693. Widget _label(String t, {bool required = false}) {
  694. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  695. return Text.rich(
  696. TextSpan(
  697. children: [
  698. TextSpan(
  699. text: t,
  700. style: TextStyle(
  701. fontSize: AppFontSizes.subtitle,
  702. color: colors.textSecondary,
  703. ),
  704. ),
  705. if (required)
  706. TextSpan(
  707. text: ' *',
  708. style: TextStyle(
  709. fontSize: AppFontSizes.subtitle,
  710. color: colors.danger,
  711. ),
  712. ),
  713. ],
  714. ),
  715. );
  716. }
  717. }