547 lines
16 KiB
Dart
547 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../../data/models/referral_model.dart';
|
||
import '../../data/datasources/remote/referral_remote_datasource.dart';
|
||
|
||
/// 伞下树节点数据模型
|
||
class TeamTreeNode {
|
||
final String accountSequence;
|
||
final int personalPlantingCount;
|
||
final int teamPlantingCount;
|
||
final int directReferralCount;
|
||
List<TeamTreeNode>? children;
|
||
bool isExpanded;
|
||
bool isLoading;
|
||
|
||
TeamTreeNode({
|
||
required this.accountSequence,
|
||
required this.personalPlantingCount,
|
||
required this.teamPlantingCount,
|
||
required this.directReferralCount,
|
||
this.children,
|
||
this.isExpanded = false,
|
||
this.isLoading = false,
|
||
});
|
||
|
||
/// 是否有下级
|
||
bool get hasChildren => directReferralCount > 0;
|
||
|
||
/// 从 DirectReferralInfo 创建
|
||
factory TeamTreeNode.fromDirectReferralInfo(DirectReferralInfo info) {
|
||
return TeamTreeNode(
|
||
accountSequence: info.accountSequence,
|
||
personalPlantingCount: info.personalPlantingCount,
|
||
teamPlantingCount: info.teamPlantingCount,
|
||
directReferralCount: info.directReferralCount,
|
||
);
|
||
}
|
||
|
||
/// 创建根节点(自己)
|
||
factory TeamTreeNode.createRoot({
|
||
required String accountSequence,
|
||
required int personalPlantingCount,
|
||
required int teamPlantingCount,
|
||
required int directReferralCount,
|
||
}) {
|
||
return TeamTreeNode(
|
||
accountSequence: accountSequence,
|
||
personalPlantingCount: personalPlantingCount,
|
||
teamPlantingCount: teamPlantingCount,
|
||
directReferralCount: directReferralCount,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 伞下树组件
|
||
class TeamTreeWidget extends StatefulWidget {
|
||
final TeamTreeNode rootNode;
|
||
final ReferralRemoteDataSource referralDataSource;
|
||
|
||
const TeamTreeWidget({
|
||
super.key,
|
||
required this.rootNode,
|
||
required this.referralDataSource,
|
||
});
|
||
|
||
@override
|
||
State<TeamTreeWidget> createState() => _TeamTreeWidgetState();
|
||
}
|
||
|
||
class _TeamTreeWidgetState extends State<TeamTreeWidget> {
|
||
// 缓存已加载的节点数据
|
||
final Map<String, List<TeamTreeNode>> _childrenCache = {};
|
||
|
||
// 节点框的尺寸
|
||
static const double nodeWidth = 80.0;
|
||
static const double nodeHeight = 60.0;
|
||
static const double nodeHorizontalSpacing = 12.0;
|
||
static const double nodeVerticalSpacing = 40.0;
|
||
|
||
// 当前容器宽度(由 LayoutBuilder 传递)
|
||
double _containerWidth = 0;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// 使用实际容器宽度而不是屏幕宽度
|
||
_containerWidth = constraints.maxWidth;
|
||
|
||
return SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: SingleChildScrollView(
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(
|
||
minWidth: _containerWidth,
|
||
),
|
||
child: Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: _buildTreeLevel([widget.rootNode], 0),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 构建树的一层
|
||
Widget _buildTreeLevel(List<TeamTreeNode> nodes, int level) {
|
||
if (nodes.isEmpty) return const SizedBox.shrink();
|
||
|
||
// 显示所有节点,允许左右滚动
|
||
return Column(
|
||
children: [
|
||
// 当前层的节点
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
for (int i = 0; i < nodes.length; i++) ...[
|
||
_buildNodeWithChildren(nodes[i]),
|
||
if (i < nodes.length - 1) SizedBox(width: nodeHorizontalSpacing),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 构建单个节点及其子节点
|
||
Widget _buildNodeWithChildren(TeamTreeNode node) {
|
||
return Column(
|
||
children: [
|
||
// 节点本身
|
||
_buildNodeBox(node),
|
||
// 如果展开了且有子节点,显示连线和子节点
|
||
if (node.isExpanded && node.children != null && node.children!.isNotEmpty) ...[
|
||
// 连接线
|
||
CustomPaint(
|
||
size: Size(_calculateSubtreeWidth(node.children!), nodeVerticalSpacing),
|
||
painter: _TreeLinePainter(
|
||
parentWidth: nodeWidth,
|
||
childCount: node.children!.length,
|
||
childWidth: nodeWidth,
|
||
spacing: nodeHorizontalSpacing,
|
||
),
|
||
),
|
||
// 子节点层
|
||
_buildTreeLevel(node.children!, 0),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 计算子树宽度
|
||
double _calculateSubtreeWidth(List<TeamTreeNode> children) {
|
||
if (children.isEmpty) return nodeWidth;
|
||
return children.length * nodeWidth + (children.length - 1) * nodeHorizontalSpacing;
|
||
}
|
||
|
||
/// 构建节点框
|
||
Widget _buildNodeBox(TeamTreeNode node) {
|
||
return GestureDetector(
|
||
onTap: () => _handleNodeTap(node),
|
||
onLongPress: () => _showNodeDetails(node),
|
||
child: Container(
|
||
width: nodeWidth,
|
||
height: nodeHeight,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFF5E6),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: const Color(0xFFFF6B00),
|
||
width: 1,
|
||
),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x1A000000), // 10% opacity black
|
||
blurRadius: 4,
|
||
offset: Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: node.isLoading
|
||
? const Center(
|
||
child: SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFFF6B00)),
|
||
),
|
||
),
|
||
)
|
||
: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
// 序列号(简化显示)
|
||
Text(
|
||
_formatAccountSequence(node.accountSequence),
|
||
style: const TextStyle(
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w600,
|
||
color: Color(0xFF5D4037),
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
// 个人/团队认种数
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(
|
||
'${node.personalPlantingCount}/${node.teamPlantingCount}',
|
||
style: const TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w500,
|
||
color: Color(0xCC5D4037),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
// 展开/收起按钮
|
||
if (node.hasChildren)
|
||
Container(
|
||
width: 20,
|
||
height: 16,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFF6B00),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
node.isExpanded ? '−' : '+',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.white,
|
||
height: 1,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 格式化账户序列号(简化显示)
|
||
String _formatAccountSequence(String seq) {
|
||
if (seq.length > 8) {
|
||
// D25121300001 -> ...00001
|
||
return '...${seq.substring(seq.length - 5)}';
|
||
}
|
||
return seq;
|
||
}
|
||
|
||
/// 处理节点点击
|
||
Future<void> _handleNodeTap(TeamTreeNode node) async {
|
||
if (!node.hasChildren) return;
|
||
|
||
if (node.isExpanded) {
|
||
// 收起节点
|
||
setState(() {
|
||
node.isExpanded = false;
|
||
});
|
||
} else {
|
||
// 展开节点
|
||
if (node.children != null) {
|
||
// 已经加载过,直接展开
|
||
setState(() {
|
||
node.isExpanded = true;
|
||
});
|
||
} else {
|
||
// 需要加载子节点
|
||
await _loadChildren(node);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 加载子节点
|
||
Future<void> _loadChildren(TeamTreeNode node) async {
|
||
// 检查缓存
|
||
if (_childrenCache.containsKey(node.accountSequence)) {
|
||
setState(() {
|
||
node.children = _childrenCache[node.accountSequence];
|
||
node.isExpanded = true;
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 显示加载状态
|
||
setState(() {
|
||
node.isLoading = true;
|
||
});
|
||
|
||
try {
|
||
final response = await widget.referralDataSource.getUserDirectReferrals(
|
||
accountSequence: node.accountSequence,
|
||
);
|
||
|
||
final children = response.referrals
|
||
.map((info) => TeamTreeNode.fromDirectReferralInfo(info))
|
||
.toList();
|
||
|
||
// 缓存结果
|
||
_childrenCache[node.accountSequence] = children;
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
node.children = children;
|
||
node.isExpanded = true;
|
||
node.isLoading = false;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint('加载子节点失败: $e');
|
||
if (mounted) {
|
||
setState(() {
|
||
node.isLoading = false;
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('加载失败: $e'),
|
||
backgroundColor: Colors.red,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 显示节点详情弹窗
|
||
void _showNodeDetails(TeamTreeNode node) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
backgroundColor: Colors.white,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||
),
|
||
builder: (context) => _NodeDetailsSheet(
|
||
node: node,
|
||
onExpandTap: node.hasChildren
|
||
? () {
|
||
Navigator.pop(context);
|
||
_handleNodeTap(node);
|
||
}
|
||
: null,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 树形连接线绘制器
|
||
class _TreeLinePainter extends CustomPainter {
|
||
final double parentWidth;
|
||
final int childCount;
|
||
final double childWidth;
|
||
final double spacing;
|
||
|
||
_TreeLinePainter({
|
||
required this.parentWidth,
|
||
required this.childCount,
|
||
required this.childWidth,
|
||
required this.spacing,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
if (childCount == 0) return;
|
||
|
||
final paint = Paint()
|
||
..color = const Color(0xFFFF6B00)
|
||
..strokeWidth = 1.5
|
||
..style = PaintingStyle.stroke;
|
||
|
||
// 父节点中心点
|
||
final parentCenterX = size.width / 2;
|
||
const parentBottomY = 0.0;
|
||
|
||
// 中间连接点
|
||
final midY = size.height / 2;
|
||
|
||
// 计算子节点位置
|
||
final totalChildrenWidth = childCount * childWidth + (childCount - 1) * spacing;
|
||
final startX = (size.width - totalChildrenWidth) / 2;
|
||
|
||
// 从父节点向下画垂直线到中间
|
||
canvas.drawLine(
|
||
Offset(parentCenterX, parentBottomY),
|
||
Offset(parentCenterX, midY),
|
||
paint,
|
||
);
|
||
|
||
if (childCount == 1) {
|
||
// 只有一个子节点,直接画直线到底部
|
||
canvas.drawLine(
|
||
Offset(parentCenterX, midY),
|
||
Offset(parentCenterX, size.height),
|
||
paint,
|
||
);
|
||
} else {
|
||
// 多个子节点,画水平线
|
||
final firstChildCenterX = startX + childWidth / 2;
|
||
final lastChildCenterX = startX + (childCount - 1) * (childWidth + spacing) + childWidth / 2;
|
||
|
||
canvas.drawLine(
|
||
Offset(firstChildCenterX, midY),
|
||
Offset(lastChildCenterX, midY),
|
||
paint,
|
||
);
|
||
|
||
// 从水平线向下画垂直线到每个子节点
|
||
for (int i = 0; i < childCount; i++) {
|
||
final childCenterX = startX + i * (childWidth + spacing) + childWidth / 2;
|
||
canvas.drawLine(
|
||
Offset(childCenterX, midY),
|
||
Offset(childCenterX, size.height),
|
||
paint,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|
||
|
||
/// 节点详情弹窗
|
||
class _NodeDetailsSheet extends StatelessWidget {
|
||
final TeamTreeNode node;
|
||
final VoidCallback? onExpandTap;
|
||
|
||
const _NodeDetailsSheet({
|
||
required this.node,
|
||
this.onExpandTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 标题栏
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text(
|
||
'成员详情',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: Color(0xFF5D4037),
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => Navigator.pop(context),
|
||
child: const Icon(
|
||
Icons.close,
|
||
color: Color(0xFF999999),
|
||
size: 24,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// 序列号
|
||
_buildDetailRow('序列号', node.accountSequence),
|
||
const SizedBox(height: 12),
|
||
|
||
// 个人认种数
|
||
_buildDetailRow('个人认种', '${node.personalPlantingCount} 棵'),
|
||
const SizedBox(height: 12),
|
||
|
||
// 团队认种数
|
||
_buildDetailRow('团队认种', '${node.teamPlantingCount} 棵'),
|
||
const SizedBox(height: 12),
|
||
|
||
// 直推人数
|
||
_buildDetailRow('直推人数', '${node.directReferralCount} 人'),
|
||
const SizedBox(height: 24),
|
||
|
||
// 操作按钮
|
||
if (onExpandTap != null)
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: onExpandTap,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFFFF6B00),
|
||
foregroundColor: Colors.white,
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
),
|
||
child: Text(
|
||
node.isExpanded ? '收起下级' : '展开下级 (${node.directReferralCount}人)',
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 底部安全区域
|
||
SizedBox(height: MediaQuery.of(context).padding.bottom + 8),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDetailRow(String label, String value) {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
color: Color(0xFF8B5A2B),
|
||
),
|
||
),
|
||
Flexible(
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.centerRight,
|
||
child: Text(
|
||
value,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: Color(0xFF5D4037),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|