form_field_row.dart 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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: 2,
  48. child: Text.rich(
  49. TextSpan(
  50. children: [
  51. TextSpan(
  52. text: label,
  53. style: TextStyle(
  54. fontSize: AppFontSizes.subtitle,
  55. color: colors.textSecondary,
  56. ),
  57. ),
  58. if (required)
  59. TextSpan(
  60. text: ' *',
  61. style: TextStyle(
  62. fontSize: AppFontSizes.subtitle,
  63. color: colors.danger,
  64. ),
  65. ),
  66. ],
  67. ),
  68. maxLines: 1,
  69. overflow: TextOverflow.ellipsis,
  70. ),
  71. ),
  72. const SizedBox(width: 16),
  73. Expanded(
  74. flex: 3,
  75. child: LayoutBuilder(
  76. builder: (context, constraints) {
  77. final textStyle = TextStyle(
  78. fontSize: AppFontSizes.subtitle,
  79. fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
  80. color: hasValue
  81. ? colors.textPrimary
  82. : colors.textPlaceholder,
  83. );
  84. final displayText = hasValue
  85. ? value!
  86. : (hint ??
  87. AppLocalizations.of(
  88. context,
  89. ).get('pleaseSelectOrFill'));
  90. // 测量文字宽度(需传入 textScaler,否则按 1.0 倍测量,
  91. // 与 Text 组件的实际渲染宽度不一致)
  92. final tp = TextPainter(
  93. text: TextSpan(text: displayText, style: textStyle),
  94. textDirection: TextDirection.ltr,
  95. textScaler: MediaQuery.textScalerOf(context),
  96. maxLines: 1,
  97. )..layout();
  98. // 预先计算图标占位(close/chevron 固定出现,iconSpace > 0)
  99. // 更多图标是条件出现的,iconSpace 保持 0,不预扣宽度
  100. double iconSpace = 0;
  101. if (hasValue && onClear != null) {
  102. iconSpace = 20;
  103. } else if (showArrow && !readOnly) {
  104. iconSpace = 18;
  105. }
  106. final textOverflows =
  107. tp.width > (constraints.maxWidth - iconSpace);
  108. return Row(
  109. children: [
  110. Expanded(
  111. child: Text(
  112. displayText,
  113. maxLines: 1,
  114. softWrap: false,
  115. overflow:
  116. (hasValue &&
  117. readOnly &&
  118. showMoreOnOverflow &&
  119. textOverflows)
  120. ? TextOverflow.fade
  121. : TextOverflow.ellipsis,
  122. textAlign: TextAlign.end,
  123. style: textStyle,
  124. ),
  125. ),
  126. if (hasValue && onClear != null)
  127. GestureDetector(
  128. onTap: onClear,
  129. child: const Padding(
  130. padding: EdgeInsets.only(left: 4),
  131. child: Icon(
  132. Icons.close,
  133. size: 16,
  134. color: Color(0xFFBBBBBB),
  135. ),
  136. ),
  137. )
  138. else if (hasValue &&
  139. readOnly &&
  140. showMoreOnOverflow &&
  141. textOverflows)
  142. GestureDetector(
  143. onTap: () => _showFullValue(context, displayText),
  144. child: const Padding(
  145. padding: EdgeInsets.only(left: 4),
  146. child: Icon(
  147. Icons.more_horiz,
  148. size: 16,
  149. color: Color(0xFFBBBBBB),
  150. ),
  151. ),
  152. )
  153. else if (showArrow && !readOnly) ...[
  154. const SizedBox(width: 4),
  155. Icon(
  156. Icons.chevron_right,
  157. size: 14,
  158. color: colors.textPlaceholder,
  159. ),
  160. ],
  161. ],
  162. );
  163. },
  164. ),
  165. ),
  166. ],
  167. ),
  168. ),
  169. );
  170. }
  171. void _showFullValue(BuildContext context, String text) {
  172. final l10n = AppLocalizations.of(context);
  173. showDialog(
  174. context: context,
  175. builder: (ctx) => TDAlertDialog(
  176. title: label,
  177. content: text,
  178. buttonStyle: TDDialogButtonStyle.text,
  179. rightBtn: TDDialogButtonOptions(
  180. title: l10n.get('close'),
  181. action: () => Navigator.pop(ctx),
  182. ),
  183. leftBtn: TDDialogButtonOptions(
  184. title: l10n.get('copy'),
  185. action: () {
  186. Clipboard.setData(ClipboardData(text: text));
  187. Navigator.pop(ctx);
  188. },
  189. ),
  190. ),
  191. );
  192. }
  193. }