approval_timeline.dart 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import 'package:flutter/material.dart';
  2. import '../../core/i18n/app_localizations.dart';
  3. import '../../core/theme/app_colors_extension.dart';
  4. import '../models/approval_status.dart';
  5. class ApprovalTimeline extends StatelessWidget {
  6. final List<ApprovalRecord> records;
  7. final List<String> chain;
  8. final String currentApproverId;
  9. const ApprovalTimeline({
  10. super.key,
  11. required this.records,
  12. required this.chain,
  13. this.currentApproverId = '',
  14. });
  15. @override
  16. Widget build(BuildContext context) {
  17. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  18. final l10n = AppLocalizations.of(context);
  19. return Column(
  20. crossAxisAlignment: CrossAxisAlignment.start,
  21. children: [
  22. Text(
  23. l10n.get('approvalProgress'),
  24. style: TextStyle(
  25. fontSize: 13,
  26. fontWeight: FontWeight.w600,
  27. color: colors.textPrimary,
  28. ),
  29. ),
  30. const SizedBox(height: 12),
  31. ...chain.asMap().entries.map((entry) {
  32. final idx = entry.key;
  33. final approverId = entry.value;
  34. final record = records
  35. .where((r) => r.approverId == approverId)
  36. .firstOrNull;
  37. final isCurrent = approverId == currentApproverId;
  38. return _buildNode(
  39. l10n,
  40. idx,
  41. chain.length,
  42. approverId,
  43. record,
  44. isCurrent,
  45. colors,
  46. );
  47. }),
  48. ],
  49. );
  50. }
  51. Widget _buildNode(
  52. AppLocalizations l10n,
  53. int index,
  54. int total,
  55. String approverId,
  56. ApprovalRecord? record,
  57. bool isCurrent,
  58. AppColorsExtension colors,
  59. ) {
  60. final isDone = record != null && record.action == 'approve';
  61. final isRejected = record != null && record.action == 'reject';
  62. final isLast = index == total - 1;
  63. return IntrinsicHeight(
  64. child: Row(
  65. crossAxisAlignment: CrossAxisAlignment.start,
  66. children: [
  67. Column(
  68. children: [
  69. Container(
  70. width: 22,
  71. height: 22,
  72. decoration: BoxDecoration(
  73. color: isRejected
  74. ? colors.danger
  75. : isDone
  76. ? colors.success
  77. : isCurrent
  78. ? colors.primary
  79. : colors.timelineInactive,
  80. shape: BoxShape.circle,
  81. ),
  82. child: Center(
  83. child: isRejected
  84. ? const Icon(Icons.close, size: 12, color: Colors.white)
  85. : isDone
  86. ? const Icon(Icons.check, size: 12, color: Colors.white)
  87. : isCurrent
  88. ? const Text(
  89. '●',
  90. style: TextStyle(color: Colors.white, fontSize: 10),
  91. )
  92. : null,
  93. ),
  94. ),
  95. if (!isLast)
  96. Container(
  97. width: 1.5,
  98. height: 36,
  99. color: isDone ? colors.success : colors.timelineInactive,
  100. ),
  101. ],
  102. ),
  103. const SizedBox(width: 10),
  104. Expanded(
  105. child: Padding(
  106. padding: EdgeInsets.only(bottom: isLast ? 0 : 12),
  107. child: Column(
  108. crossAxisAlignment: CrossAxisAlignment.start,
  109. children: [
  110. Text(
  111. record?.approverName ?? '审批人$approverId',
  112. style: TextStyle(
  113. fontSize: 13,
  114. fontWeight: FontWeight.w500,
  115. color: isRejected
  116. ? colors.danger
  117. : isCurrent
  118. ? colors.primary
  119. : colors.textPrimary,
  120. ),
  121. ),
  122. if (record != null && record.action != 'pending') ...[
  123. const SizedBox(height: 2),
  124. Text(
  125. '${record.action == 'approve' ? l10n.get('approved') : l10n.get('rejected')} · ${record.approvalTime.toString().substring(0, 16)}',
  126. style: TextStyle(
  127. fontSize: 11,
  128. color: colors.textPlaceholder,
  129. ),
  130. ),
  131. if (record.opinion.isNotEmpty)
  132. Padding(
  133. padding: const EdgeInsets.only(top: 2),
  134. child: Text(
  135. '${l10n.get('opinion')}${record.opinion}',
  136. style: TextStyle(
  137. fontSize: 11,
  138. color: colors.textSecondary,
  139. ),
  140. ),
  141. ),
  142. ] else if (isCurrent) ...[
  143. const SizedBox(height: 2),
  144. Text(
  145. l10n.get('currentNode'),
  146. style: TextStyle(fontSize: 11, color: colors.primary),
  147. ),
  148. ] else ...[
  149. const SizedBox(height: 2),
  150. Text(
  151. l10n.get('waitHandle'),
  152. style: TextStyle(
  153. fontSize: 11,
  154. color: colors.textPlaceholder,
  155. ),
  156. ),
  157. ],
  158. ],
  159. ),
  160. ),
  161. ),
  162. ],
  163. ),
  164. );
  165. }
  166. }