form_field_row.dart 8.9 KB

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