form_field_row.dart 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../../core/theme/app_colors_extension.dart';
  6. import '../../core/i18n/app_localizations.dart';
  7. /// 表单字段行,左侧标签 + 右侧值 + 可选箭头。
  8. ///
  9. /// [required] 为 true 时标签前显示红色星号。
  10. class FormFieldRow extends StatelessWidget {
  11. final String label;
  12. final String? value;
  13. final String? hint;
  14. final bool showArrow;
  15. final bool readOnly;
  16. final bool required;
  17. final VoidCallback? onTap;
  18. final VoidCallback? onClear;
  19. final bool bold;
  20. final bool showMoreOnOverflow;
  21. const FormFieldRow({
  22. super.key,
  23. required this.label,
  24. this.value,
  25. this.hint,
  26. this.showArrow = true,
  27. this.readOnly = false,
  28. this.required = false,
  29. this.onTap,
  30. this.onClear,
  31. this.bold = false,
  32. this.showMoreOnOverflow = true,
  33. });
  34. @override
  35. Widget build(BuildContext context) {
  36. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  37. final hasValue = value != null && value!.isNotEmpty;
  38. return GestureDetector(
  39. onTap: onTap,
  40. behavior: HitTestBehavior.opaque,
  41. child: Container(
  42. padding: EdgeInsets.zero,
  43. child: Row(
  44. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  45. children: [
  46. Flexible(
  47. flex: 1,
  48. child: LayoutBuilder(
  49. builder: (context, constraints) {
  50. final labelStyle = TextStyle(
  51. fontSize: AppFontSizes.subtitle,
  52. color: colors.textSecondary,
  53. );
  54. final requiredStyle = TextStyle(
  55. fontSize: AppFontSizes.subtitle,
  56. color: colors.danger,
  57. );
  58. final labelText = label;
  59. final reqText = required ? ' *' : '';
  60. // 测量完整标签宽度
  61. final fullTp = TextPainter(
  62. text: TextSpan(
  63. text: '$labelText$reqText',
  64. style: labelStyle,
  65. children: required
  66. ? [TextSpan(text: reqText, style: requiredStyle)]
  67. : null,
  68. ),
  69. textDirection: TextDirection.ltr,
  70. textScaler: MediaQuery.textScalerOf(context),
  71. maxLines: 1,
  72. )..layout();
  73. final labelOverflows =
  74. fullTp.width > constraints.maxWidth;
  75. return Row(
  76. mainAxisSize: MainAxisSize.min,
  77. children: [
  78. Flexible(
  79. child: Text.rich(
  80. TextSpan(
  81. children: [
  82. TextSpan(text: labelText, style: labelStyle),
  83. if (required)
  84. TextSpan(text: reqText, style: requiredStyle),
  85. ],
  86. ),
  87. maxLines: 1,
  88. softWrap: false,
  89. overflow: TextOverflow.fade,
  90. ),
  91. ),
  92. if (labelOverflows)
  93. GestureDetector(
  94. onTap: () {},
  95. child: Tooltip(
  96. message: labelText,
  97. triggerMode: TooltipTriggerMode.tap,
  98. preferBelow: false,
  99. child: Padding(
  100. padding: const EdgeInsets.only(left: 2),
  101. child: Icon(
  102. Icons.info_outline,
  103. size: 14,
  104. color: colors.textPlaceholder,
  105. ),
  106. ),
  107. ),
  108. ),
  109. ],
  110. );
  111. },
  112. ),
  113. ),
  114. const SizedBox(width: 16),
  115. Expanded(
  116. flex: 2,
  117. child: LayoutBuilder(
  118. builder: (context, constraints) {
  119. final textStyle = TextStyle(
  120. fontSize: AppFontSizes.subtitle,
  121. fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
  122. color: hasValue
  123. ? colors.textPrimary
  124. : colors.textPlaceholder,
  125. );
  126. final displayText = hasValue
  127. ? value!
  128. : (hint ??
  129. AppLocalizations.of(
  130. context,
  131. ).get('pleaseSelectOrFill'));
  132. // 测量文字宽度(需传入 textScaler,否则按 1.0 倍测量,
  133. // 与 Text 组件的实际渲染宽度不一致)
  134. final tp = TextPainter(
  135. text: TextSpan(text: displayText, style: textStyle),
  136. textDirection: TextDirection.ltr,
  137. textScaler: MediaQuery.textScalerOf(context),
  138. maxLines: 1,
  139. )..layout();
  140. // 预先计算图标占位(close/chevron 固定出现,iconSpace > 0)
  141. // 更多图标是条件出现的,iconSpace 保持 0,不预扣宽度
  142. double iconSpace = 0;
  143. if (hasValue && onClear != null) {
  144. iconSpace = 20;
  145. } else if (showArrow && !readOnly) {
  146. iconSpace = 18;
  147. }
  148. final textOverflows =
  149. tp.width > (constraints.maxWidth - iconSpace);
  150. return Row(
  151. children: [
  152. Expanded(
  153. child: Text(
  154. displayText,
  155. maxLines: 1,
  156. softWrap: false,
  157. overflow:
  158. (hasValue &&
  159. readOnly &&
  160. showMoreOnOverflow &&
  161. textOverflows)
  162. ? TextOverflow.fade
  163. : TextOverflow.ellipsis,
  164. textAlign: TextAlign.end,
  165. style: textStyle,
  166. ),
  167. ),
  168. if (hasValue && onClear != null)
  169. GestureDetector(
  170. onTap: onClear,
  171. child: const Padding(
  172. padding: EdgeInsets.only(left: 4),
  173. child: Icon(
  174. Icons.close,
  175. size: 16,
  176. color: Color(0xFFBBBBBB),
  177. ),
  178. ),
  179. )
  180. else if (hasValue &&
  181. readOnly &&
  182. showMoreOnOverflow &&
  183. textOverflows)
  184. GestureDetector(
  185. onTap: () => _showFullValue(context, displayText),
  186. child: const Padding(
  187. padding: EdgeInsets.only(left: 4),
  188. child: Icon(
  189. Icons.more_horiz,
  190. size: 16,
  191. color: Color(0xFFBBBBBB),
  192. ),
  193. ),
  194. )
  195. else if (showArrow && !readOnly) ...[
  196. const SizedBox(width: 4),
  197. Icon(
  198. Icons.chevron_right,
  199. size: 14,
  200. color: colors.textPlaceholder,
  201. ),
  202. ],
  203. ],
  204. );
  205. },
  206. ),
  207. ),
  208. ],
  209. ),
  210. ),
  211. );
  212. }
  213. void _showFullValue(BuildContext context, String text) {
  214. final l10n = AppLocalizations.of(context);
  215. showDialog(
  216. context: context,
  217. builder: (ctx) => TDAlertDialog(
  218. title: label,
  219. content: text,
  220. buttonStyle: TDDialogButtonStyle.text,
  221. rightBtn: TDDialogButtonOptions(
  222. title: l10n.get('close'),
  223. action: () => Navigator.pop(ctx),
  224. ),
  225. leftBtn: TDDialogButtonOptions(
  226. title: l10n.get('copy'),
  227. action: () {
  228. Clipboard.setData(ClipboardData(text: text));
  229. Navigator.pop(ctx);
  230. },
  231. ),
  232. ),
  233. );
  234. }
  235. }