| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- import 'package:flutter/services.dart';
- import 'package:flutter/material.dart';
- import '../../core/l10n/app_localizations.dart';
- import '../../core/theme/app_colors.dart';
- import '../../core/utils/top_toast.dart';
- import '../../data/models/copy_trading/copy_position.dart';
- /// Shared position card component for displaying copy trading positions
- class CopyPositionCard extends StatelessWidget {
- const CopyPositionCard({
- super.key,
- required this.position,
- required this.isHistory,
- this.onClose,
- });
- final CopyPosition position;
- final bool isHistory;
- final void Function(String positionId)? onClose;
- static const _avatarColors = [
- Color(0xFFf7931a), Color(0xFF627eea), Color(0xFF9945ff),
- Color(0xFFf3ba2f), Color(0xFF2775ca),
- ];
- Color get _avatarBg =>
- _avatarColors[position.traderName.codeUnitAt(0) % _avatarColors.length];
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final isLong = position.isLong;
- final directionColor = isLong ? AppColors.rise : AppColors.fall;
- final directionLabel = isLong ? l10n.longBull : l10n.shortBear;
- final pnlColor = position.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall;
- final pnlSign = position.unrealizedPnl >= 0 ? '+' : '';
- final roiSign = position.roi >= 0 ? '+' : '';
- return Container(
- margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- borderRadius: BorderRadius.circular(16),
- boxShadow: [
- BoxShadow(color: Colors.black.withAlpha(18), blurRadius: 12, offset: const Offset(0, 2)),
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (isHistory)
- // 历史跟单:品种在左,交易员头像+名称在右
- Row(
- children: [
- Expanded(
- child: Text(
- position.symbol,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700),
- ),
- ),
- TraderAvatar(
- name: position.traderName,
- avatarUrl: position.traderAvatar,
- bgColor: _avatarBg,
- size: 32,
- ),
- const SizedBox(width: 8),
- Flexible(
- child: Text(position.traderName,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)),
- ),
- ],
- )
- else
- // 当前跟单:交易员头像+名称在左,平仓按钮在右
- Row(
- children: [
- TraderAvatar(
- name: position.traderName,
- avatarUrl: position.traderAvatar,
- bgColor: _avatarBg,
- size: 38,
- ),
- const SizedBox(width: 10),
- Expanded(
- child: Text(
- position.traderName,
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600),
- ),
- ),
- ElevatedButton(
- onPressed: () => onClose?.call(position.id),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- foregroundColor: Colors.black,
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
- minimumSize: Size.zero,
- tapTargetSize: MaterialTapTargetSize.shrinkWrap,
- elevation: 0,
- ),
- child: Text(l10n.closePositionBtn, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
- ),
- ],
- ),
- const SizedBox(height: 10),
- // 品种行(当前跟单时显示)
- if (!isHistory)
- Wrap(
- spacing: 6,
- runSpacing: 6,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- Text(position.symbol,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w700)),
- Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
- Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
- ],
- )
- else
- // 历史跟单:direction + 永续 + positionType + leverage
- Wrap(
- spacing: 6,
- runSpacing: 4,
- children: [
- Badge(text: directionLabel, bgColor: directionColor.withValues(alpha: 0.12), textColor: directionColor),
- Badge(text: l10n.perpetual, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- Badge(text: position.positionType == '逐仓' ? l10n.isolatedMargin : l10n.crossMargin, bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(153), borderColor: cs.onSurface.withAlpha(60)),
- Badge(text: '${position.leverage}x', bgColor: cs.onSurface.withAlpha(20), textColor: cs.onSurface.withAlpha(180), borderColor: cs.onSurface.withAlpha(60)),
- ],
- ),
- const SizedBox(height: 10),
- if (!isHistory) ...[
- // 当前跟单数据行
- Row(
- children: [
- DataCell(label: '${l10n.openAvgPrice}(USDT)', value: position.openPrice.toStringAsFixed(1)),
- DataCell(label: '${l10n.currentPrice}(USDT)', value: position.currentPrice.toStringAsFixed(1)),
- DataCell(label: '${l10n.currentMarginLabel}(USDT)', value: position.margin.toStringAsFixed(2)),
- ],
- ),
- const SizedBox(height: 8),
- Row(
- children: [
- DataCell(label: '${l10n.quantityLabel}(${_baseAsset(position.symbol)})', value: position.quantity.toStringAsFixed(4)),
- DataCell(label: l10n.profitRateLabel, value: '$roiSign${position.roi.toStringAsFixed(2)}%', valueColor: pnlColor),
- DataCell(label: l10n.profitUsdtLabel, value: '$pnlSign${position.unrealizedPnl.toStringAsFixed(3)}', valueColor: pnlColor),
- ],
- ),
- ] else ...[
- // 历史跟单数据行:数量 / 收益 / 收益率(三列,最后列右对齐)
- Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('${l10n.quantityLabel}(${_baseAsset(position.symbol)})',
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 4),
- Text(position.quantity.toStringAsFixed(4),
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(l10n.profitUsdtLabel,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 4),
- Text('$pnlSign${position.unrealizedPnl.toStringAsFixed(4)}',
- style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(l10n.profitRateLabel,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 4),
- Text('$roiSign${position.roi.toStringAsFixed(2)}%',
- style: TextStyle(color: pnlColor, fontSize: 15, fontWeight: FontWeight.w700)),
- ],
- ),
- ),
- ],
- ),
- const SizedBox(height: 16),
- Divider(height: 1, thickness: 1, color: cs.outlineVariant.withAlpha(60)),
- const SizedBox(height: 12),
- // 底部:开仓均价(左) / 平仓均价(右对齐)
- Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('${l10n.openAvgPrice}(USDT)',
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 3),
- Text(position.openPrice.toStringAsFixed(1),
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
- const SizedBox(height: 2),
- Text(_formatDate(position.openTime),
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11)),
- ],
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text('${l10n.closeAvgPrice}(USDT)',
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 3),
- Text(
- position.closePrice != null ? position.closePrice!.toStringAsFixed(1) : '--',
- style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700),
- ),
- const SizedBox(height: 2),
- Text(
- position.closeTime != null ? _formatDate(position.closeTime!) : '--',
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 11),
- ),
- ],
- ),
- ],
- ),
- ],
- if (!isHistory) ...[
- const SizedBox(height: 10),
- Row(
- children: [
- Text(
- '${l10n.openTime} ${_formatDate(position.openTime)}',
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11),
- ),
- const Spacer(),
- Text('${l10n.positionId} ${position.id}',
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(width: 4),
- GestureDetector(
- onTap: () {
- Clipboard.setData(ClipboardData(text: position.id));
- showTopToast(context, message: l10n.copyIdSuccess, backgroundColor: AppColors.rise);
- },
- child: Icon(Icons.copy_outlined, size: 14, color: cs.onSurface.withAlpha(153)),
- ),
- ],
- ),
- ],
- ],
- ),
- );
- }
- String _baseAsset(String symbol) {
- final s = symbol.replaceAll(' 永续', '');
- final parts = s.split('/');
- return parts.isNotEmpty ? parts[0] : s;
- }
- String _formatDate(DateTime dt) =>
- '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
- '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
- }
- /// Trader avatar display
- class TraderAvatar extends StatelessWidget {
- const TraderAvatar({
- super.key,
- required this.name,
- required this.bgColor,
- required this.size,
- this.avatarUrl,
- });
- final String name;
- final String? avatarUrl;
- final Color bgColor;
- final double size;
- @override
- Widget build(BuildContext context) {
- final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
- if (avatarUrl != null && avatarUrl!.isNotEmpty) {
- return ClipOval(
- child: Image.network(
- avatarUrl!,
- width: size,
- height: size,
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => _fallback(letter),
- ),
- );
- }
- return _fallback(letter);
- }
- Widget _fallback(String letter) => Container(
- width: size,
- height: size,
- decoration: BoxDecoration(color: bgColor, shape: BoxShape.circle),
- child: Center(
- child: Text(letter,
- style: TextStyle(
- color: Colors.white,
- fontSize: size * 0.42,
- fontWeight: FontWeight.w700)),
- ),
- );
- }
- /// Badge component for direction, leverage, position type
- class Badge extends StatelessWidget {
- const Badge({
- super.key,
- required this.text,
- required this.bgColor,
- required this.textColor,
- this.borderColor,
- });
- final String text;
- final Color bgColor;
- final Color textColor;
- final Color? borderColor;
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
- decoration: BoxDecoration(
- color: bgColor,
- borderRadius: BorderRadius.circular(4),
- border: borderColor != null ? Border.all(color: borderColor!, width: 0.8) : null,
- ),
- child: Text(text, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: textColor, fontSize: 11, fontWeight: FontWeight.w500)),
- );
- }
- }
- /// Data label-value cell
- class DataCell extends StatelessWidget {
- const DataCell({
- super.key,
- required this.label,
- required this.value,
- this.valueColor,
- });
- final String label;
- final String value;
- final Color? valueColor;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 2),
- Text(value,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: valueColor ?? cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w500)),
- ],
- ),
- );
- }
- }
|