copy_position_card.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import 'package:flutter/services.dart';
  2. import 'package:flutter/material.dart';
  3. import '../../core/l10n/app_localizations.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../../core/utils/top_toast.dart';
  6. import '../../data/models/copy_trading/copy_position.dart';
  7. /// Shared position card component for displaying copy trading positions
  8. class CopyPositionCard extends StatelessWidget {
  9. const CopyPositionCard({
  10. super.key,
  11. required this.position,
  12. required this.isHistory,
  13. this.onClose,
  14. });
  15. final CopyPosition position;
  16. final bool isHistory;
  17. final void Function(String positionId)? onClose;
  18. static const _avatarColors = [
  19. Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff),
  20. Color(0xFFf3ba2f), Color(0xFF2775ca),
  21. ];
  22. Color get _avatarBg =>
  23. _avatarColors[position.traderName.codeUnitAt(0) % _avatarColors.length];
  24. @override
  25. Widget build(BuildContext context) {
  26. final cs = Theme.of(context).colorScheme;
  27. final l10n = AppLocalizations.of(context)!;
  28. final isDark = Theme.of(context).brightness == Brightness.dark;
  29. final isLong = position.isLong;
  30. final directionColor = isLong ? AppColors.rise : AppColors.fall;
  31. final directionLabel = isLong ? l10n.longBull : l10n.shortBear;
  32. final pnlColor = position.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall;
  33. final pnlSign = position.unrealizedPnl >= 0 ? '+' : '';
  34. final roiSign = position.roi >= 0 ? '+' : '';
  35. return Container(
  36. margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  37. padding: const EdgeInsets.all(16),
  38. decoration: BoxDecoration(
  39. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  40. borderRadius: BorderRadius.circular(16),
  41. boxShadow: [
  42. BoxShadow(color: Colors.black.withAlpha(18), blurRadius: 12, offset: const Offset(0, 2)),
  43. ],
  44. ),
  45. child: Column(
  46. crossAxisAlignment: CrossAxisAlignment.start,
  47. children: [
  48. if (isHistory)
  49. // 历史跟单:品种在左,交易员头像+名称在右
  50. Row(
  51. children: [
  52. Expanded(
  53. child: Text(
  54. position.symbol,
  55. maxLines: 1,
  56. overflow: TextOverflow.ellipsis,
  57. style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
  58. ),
  59. ),
  60. TraderAvatar(
  61. name: position.traderName,
  62. avatarUrl: position.traderAvatar,
  63. bgColor: _avatarBg,
  64. size: 32,
  65. ),
  66. const SizedBox(width: 8),
  67. Flexible(
  68. child: Text(position.traderName,
  69. maxLines: 1,
  70. overflow: TextOverflow.ellipsis,
  71. style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)),
  72. ),
  73. ],
  74. )
  75. else
  76. // 当前跟单:交易员头像+名称在左,平仓按钮在右
  77. Row(
  78. children: [
  79. TraderAvatar(
  80. name: position.traderName,
  81. avatarUrl: position.traderAvatar,
  82. bgColor: _avatarBg,
  83. size: 38,
  84. ),
  85. const SizedBox(width: 10),
  86. Expanded(
  87. child: Text(
  88. position.traderName,
  89. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600),
  90. ),
  91. ),
  92. ElevatedButton(
  93. onPressed: () => onClose?.call(position.id),
  94. style: ElevatedButton.styleFrom(
  95. backgroundColor: AppColors.brand,
  96. foregroundColor: Colors.black,
  97. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  98. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  99. minimumSize: Size.zero,
  100. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  101. elevation: 0,
  102. ),
  103. child: Text(l10n.closePositionBtn, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
  104. ),
  105. ],
  106. ),
  107. const SizedBox(height: 10),
  108. // 品种行(当前跟单时显示)
  109. if (!isHistory)
  110. Wrap(
  111. spacing: 6,
  112. runSpacing: 6,
  113. crossAxisAlignment: WrapCrossAlignment.center,
  114. children: [
  115. Text(position.symbol,
  116. maxLines: 1,
  117. overflow: TextOverflow.ellipsis,
  118. style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)),
  119. Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
  120. Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  121. Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  122. Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
  123. ],
  124. )
  125. else
  126. // 历史跟单:direction + 永续 + positionType + leverage
  127. Wrap(
  128. spacing: 6,
  129. runSpacing: 4,
  130. children: [
  131. Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
  132. Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  133. Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
  134. Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
  135. ],
  136. ),
  137. const SizedBox(height: 10),
  138. if (!isHistory) ...[
  139. // 当前跟单数据行
  140. Row(
  141. children: [
  142. DataCell(label: '${l10n.openAvgPrice}(USDT)', value: position.openPrice.toStringAsFixed(1)),
  143. DataCell(label: '${l10n.currentPrice}(USDT)', value: position.currentPrice.toStringAsFixed(1)),
  144. DataCell(label: '${l10n.currentMarginLabel}(USDT)', value: position.margin.toStringAsFixed(2)),
  145. ],
  146. ),
  147. const SizedBox(height: 8),
  148. Row(
  149. children: [
  150. DataCell(label: '${l10n.quantityLabel}(${_baseAsset(position.symbol)})', value: position.quantity.toStringAsFixed(4)),
  151. DataCell(label: l10n.profitRateLabel, value: '$roiSign${position.roi.toStringAsFixed(2)}%', valueColor: pnlColor),
  152. DataCell(label: l10n.profitUsdtLabel, value: '$pnlSign${position.unrealizedPnl.toStringAsFixed(3)}', valueColor: pnlColor),
  153. ],
  154. ),
  155. ] else ...[
  156. // 历史跟单数据行:数量 / 收益 / 收益率(三列,最后列右对齐)
  157. Row(
  158. children: [
  159. Expanded(
  160. child: Column(
  161. crossAxisAlignment: CrossAxisAlignment.start,
  162. children: [
  163. Text('${l10n.quantityLabel}(${_baseAsset(position.symbol)})',
  164. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  165. const SizedBox(height: 4),
  166. Text(position.quantity.toStringAsFixed(4),
  167. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
  168. ],
  169. ),
  170. ),
  171. Expanded(
  172. child: Column(
  173. crossAxisAlignment: CrossAxisAlignment.start,
  174. children: [
  175. Text(l10n.profitUsdtLabel,
  176. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  177. const SizedBox(height: 4),
  178. Text('$pnlSign${position.unrealizedPnl.toStringAsFixed(4)}',
  179. style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
  180. ],
  181. ),
  182. ),
  183. Expanded(
  184. child: Column(
  185. crossAxisAlignment: CrossAxisAlignment.end,
  186. children: [
  187. Text(l10n.profitRateLabel,
  188. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  189. const SizedBox(height: 4),
  190. Text('$roiSign${position.roi.toStringAsFixed(2)}%',
  191. style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
  192. ],
  193. ),
  194. ),
  195. ],
  196. ),
  197. const SizedBox(height: 16),
  198. Divider(height: 1, thickness: 1, color: cs.outlineVariant.withAlpha(60)),
  199. const SizedBox(height: 12),
  200. // 底部:开仓均价(左) / 平仓均价(右对齐)
  201. Row(
  202. children: [
  203. Expanded(
  204. child: Column(
  205. crossAxisAlignment: CrossAxisAlignment.start,
  206. children: [
  207. Text('${l10n.openAvgPrice}(USDT)',
  208. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  209. const SizedBox(height: 3),
  210. Text(position.openPrice.toStringAsFixed(1),
  211. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
  212. const SizedBox(height: 2),
  213. Text(_formatDate(position.openTime),
  214. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
  215. ],
  216. ),
  217. ),
  218. Column(
  219. crossAxisAlignment: CrossAxisAlignment.end,
  220. children: [
  221. Text('${l10n.closeAvgPrice}(USDT)',
  222. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  223. const SizedBox(height: 3),
  224. Text(
  225. position.closePrice != null ? position.closePrice!.toStringAsFixed(1) : '--',
  226. style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700),
  227. ),
  228. const SizedBox(height: 2),
  229. Text(
  230. position.closeTime != null ? _formatDate(position.closeTime!) : '--',
  231. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11),
  232. ),
  233. ],
  234. ),
  235. ],
  236. ),
  237. ],
  238. if (!isHistory) ...[
  239. const SizedBox(height: 10),
  240. Row(
  241. children: [
  242. Text(
  243. '${l10n.openTime} ${_formatDate(position.openTime)}',
  244. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11),
  245. ),
  246. const Spacer(),
  247. Text('${l10n.positionId} ${position.id}',
  248. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  249. const SizedBox(width: 4),
  250. GestureDetector(
  251. onTap: () {
  252. Clipboard.setData(ClipboardData(text: position.id));
  253. showTopToast(context, message: l10n.copyIdSuccess, backgroundColor: AppColors.rise);
  254. },
  255. child: Icon(Icons.copy_outlined, size: 14, color: cs.onSurface.withAlpha(153)),
  256. ),
  257. ],
  258. ),
  259. ],
  260. ],
  261. ),
  262. );
  263. }
  264. String _baseAsset(String symbol) {
  265. final s = symbol.replaceAll(' 永续', '');
  266. final parts = s.split('/');
  267. return parts.isNotEmpty ? parts[0] : s;
  268. }
  269. String _formatDate(DateTime dt) =>
  270. '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
  271. '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
  272. }
  273. /// Trader avatar display
  274. class TraderAvatar extends StatelessWidget {
  275. const TraderAvatar({
  276. super.key,
  277. required this.name,
  278. required this.bgColor,
  279. required this.size,
  280. this.avatarUrl,
  281. });
  282. final String name;
  283. final String? avatarUrl;
  284. final Color bgColor;
  285. final double size;
  286. @override
  287. Widget build(BuildContext context) {
  288. final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
  289. if (avatarUrl != null && avatarUrl!.isNotEmpty) {
  290. return ClipOval(
  291. child: Image.network(
  292. avatarUrl!,
  293. width: size,
  294. height: size,
  295. fit: BoxFit.cover,
  296. errorBuilder: (_, __, ___) => _fallback(letter),
  297. ),
  298. );
  299. }
  300. return _fallback(letter);
  301. }
  302. Widget _fallback(String letter) => Container(
  303. width: size,
  304. height: size,
  305. decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle),
  306. child: Center(
  307. child: Text(letter,
  308. style: TextStyle(
  309. color: Colors.white,
  310. fontSize: size * 0.42,
  311. fontWeight: FontWeight.w700)),
  312. ),
  313. );
  314. }
  315. /// Badge component for direction, leverage, position type
  316. class Badge extends StatelessWidget {
  317. const Badge({
  318. super.key,
  319. required this.text,
  320. required this.bgColor,
  321. required this.textColor,
  322. this.borderColor,
  323. });
  324. final String text;
  325. final Color bgColor;
  326. final Color textColor;
  327. final Color? borderColor;
  328. @override
  329. Widget build(BuildContext context) {
  330. return Container(
  331. padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
  332. decoration: BoxDecoration(
  333. color: bgColor,
  334. borderRadius: BorderRadius.circular(4),
  335. border: borderColor != null ? Border.all(color: borderColor!, width: 0.8) : null,
  336. ),
  337. child: Text(text, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: textColor, fontSize: 11, fontWeight: FontWeight.w500)),
  338. );
  339. }
  340. }
  341. /// Data label-value cell
  342. class DataCell extends StatelessWidget {
  343. const DataCell({
  344. super.key,
  345. required this.label,
  346. required this.value,
  347. this.valueColor,
  348. });
  349. final String label;
  350. final String value;
  351. final Color? valueColor;
  352. @override
  353. Widget build(BuildContext context) {
  354. final cs = Theme.of(context).colorScheme;
  355. return Expanded(
  356. child: Column(
  357. crossAxisAlignment: CrossAxisAlignment.start,
  358. children: [
  359. Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
  360. const SizedBox(height: 2),
  361. Text(value,
  362. maxLines: 1,
  363. overflow: TextOverflow.ellipsis,
  364. style: TextStyle(
  365. color: valueColor ?? cs.onSurface,
  366. fontSize: 13,
  367. fontWeight: FontWeight.w500)),
  368. ],
  369. ),
  370. );
  371. }
  372. }