node_list.dart 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_screenutil/flutter_screenutil.dart';
  3. import 'package:flutter_sticky_header/flutter_sticky_header.dart';
  4. import 'package:flutter_svg/flutter_svg.dart';
  5. import 'package:get/get.dart';
  6. import 'package:nomo/app/widgets/click_opacity.dart';
  7. import 'package:nomo/config/theme/theme_extensions/theme_extension.dart';
  8. import '../../../constants/assets.dart';
  9. import '../../../data/models/launch/groups.dart';
  10. import '../controllers/node_controller.dart';
  11. class NodeList extends StatefulWidget {
  12. final int tabIndex;
  13. const NodeList({super.key, required this.tabIndex});
  14. @override
  15. State<NodeList> createState() {
  16. return _NodeListState();
  17. }
  18. }
  19. class _NodeListState extends State<NodeList>
  20. with AutomaticKeepAliveClientMixin {
  21. @override
  22. bool get wantKeepAlive => true;
  23. // 获取 Controller
  24. NodeController get controller => Get.find<NodeController>();
  25. /// 获取国家的展开状态
  26. bool _getExpandedState(String countryCode) {
  27. return controller.getExpandedState(widget.tabIndex, countryCode);
  28. }
  29. /// 设置国家的展开状态
  30. void _setExpandedState(String countryCode, bool expanded) {
  31. setState(() {
  32. controller.setExpandedState(widget.tabIndex, countryCode, expanded);
  33. });
  34. }
  35. @override
  36. Widget build(BuildContext context) {
  37. super.build(context);
  38. // 获取当前 tab 的数据
  39. final data = controller.getDataByTabIndex(widget.tabIndex);
  40. if (data == null || data.tags == null || data.list == null) {
  41. return Center(
  42. child: Text(
  43. '暂无数据',
  44. style: TextStyle(fontSize: 16.sp, color: Get.reactiveTheme.hintColor),
  45. ),
  46. );
  47. }
  48. // 按 tag 分组数据
  49. final groupedData = <int, List<dynamic>>{};
  50. for (var location in data.list!) {
  51. if (location.tag != null) {
  52. groupedData.putIfAbsent(location.tag!, () => []).add(location);
  53. }
  54. }
  55. // 创建 tag 名称映射
  56. final tagNameMap = <int, String>{};
  57. for (var tag in data.tags!) {
  58. if (tag.id != null && tag.name != null) {
  59. tagNameMap[tag.id!] = tag.name!;
  60. }
  61. }
  62. // 按 sort 排序 tags
  63. final sortedTags = data.tags!.toList()
  64. ..sort((a, b) => (a.sort ?? 0).compareTo(b.sort ?? 0));
  65. return CustomScrollView(
  66. slivers: [
  67. for (var tag in sortedTags)
  68. if (groupedData.containsKey(tag.id) &&
  69. groupedData[tag.id]!.isNotEmpty)
  70. SliverStickyHeader(
  71. header: Container(
  72. color: Get.reactiveTheme.scaffoldBackgroundColor,
  73. padding: const EdgeInsets.all(16),
  74. child: Text(
  75. tag.name ?? '',
  76. style: TextStyle(
  77. color: Get.reactiveTheme.hintColor,
  78. fontSize: 16,
  79. fontWeight: FontWeight.bold,
  80. ),
  81. ),
  82. ),
  83. sliver: SliverList(
  84. delegate: SliverChildBuilderDelegate((context, i) {
  85. final locationList = groupedData[tag.id]![i];
  86. final countryCode = locationList.icon ?? '';
  87. return _CountrySection(
  88. locationList: locationList,
  89. // 传递展开状态
  90. expanded: _getExpandedState(countryCode),
  91. // 展开状态变化回调
  92. onExpandedChanged: (expanded) {
  93. _setExpandedState(countryCode, expanded);
  94. },
  95. );
  96. }, childCount: groupedData[tag.id]!.length),
  97. ),
  98. ),
  99. ],
  100. );
  101. }
  102. }
  103. class _CountrySection extends StatelessWidget {
  104. final LocationList locationList;
  105. final bool expanded;
  106. final ValueChanged<bool> onExpandedChanged;
  107. const _CountrySection({
  108. required this.locationList,
  109. required this.expanded,
  110. required this.onExpandedChanged,
  111. });
  112. @override
  113. Widget build(BuildContext context) {
  114. final countryIcon = locationList.icon ?? '';
  115. final countryName = locationList.name ?? '';
  116. final locations = locationList.locations ?? [];
  117. return Container(
  118. margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  119. decoration: BoxDecoration(
  120. color: Get.reactiveTheme.highlightColor,
  121. borderRadius: BorderRadius.circular(12),
  122. ),
  123. child: Column(
  124. children: [
  125. ClickOpacity(
  126. onTap: () => onExpandedChanged(!expanded),
  127. child: Padding(
  128. padding: EdgeInsets.all(14.w),
  129. child: Row(
  130. children: [
  131. // 国旗图标
  132. ClipRRect(
  133. borderRadius: BorderRadius.circular(4.r),
  134. child: SvgPicture.asset(
  135. Assets.getCountryFlagImage(countryIcon),
  136. width: 32.w,
  137. height: 24.w,
  138. fit: BoxFit.cover,
  139. ),
  140. ),
  141. 10.horizontalSpace,
  142. Text(
  143. countryName,
  144. style: TextStyle(
  145. fontSize: 16.sp,
  146. height: 1.5,
  147. fontWeight: FontWeight.w500,
  148. color: Get.reactiveTheme.textTheme.bodyLarge!.color,
  149. ),
  150. ),
  151. const Spacer(),
  152. // 箭头图标
  153. AnimatedRotation(
  154. turns: expanded ? 0.25 : 0.0,
  155. duration: const Duration(milliseconds: 300),
  156. child: Icon(
  157. Icons.keyboard_arrow_right,
  158. size: 20.w,
  159. color: Get.reactiveTheme.hintColor,
  160. ),
  161. ),
  162. ],
  163. ),
  164. ),
  165. ),
  166. ClipRect(
  167. child: AnimatedAlign(
  168. duration: const Duration(milliseconds: 300),
  169. curve: Curves.easeInOut,
  170. heightFactor: expanded ? 1.0 : 0.0,
  171. alignment: Alignment.topLeft,
  172. child: AnimatedOpacity(
  173. opacity: expanded ? 1.0 : 0.0,
  174. duration: const Duration(milliseconds: 300),
  175. child: Column(
  176. children: locations.map((location) {
  177. final locationName = location.name ?? '';
  178. final locationIcon = location.icon ?? countryIcon;
  179. final latency = location.latency;
  180. return ClickOpacity(
  181. onTap: () {
  182. // TODO: 处理节点选择
  183. },
  184. child: Column(
  185. children: [
  186. Divider(
  187. height: 1,
  188. color: Get.reactiveTheme.dividerColor,
  189. ),
  190. Container(
  191. alignment: Alignment.centerLeft,
  192. margin: EdgeInsets.only(
  193. top: 14.w,
  194. bottom: 14.w,
  195. left: 24.w,
  196. right: 14.w,
  197. ),
  198. child: Row(
  199. children: [
  200. ClipRRect(
  201. borderRadius: BorderRadius.circular(4.r),
  202. child: SvgPicture.asset(
  203. Assets.getCountryFlagImage(locationIcon),
  204. width: 32.w,
  205. height: 24.w,
  206. fit: BoxFit.cover,
  207. ),
  208. ),
  209. 10.horizontalSpace,
  210. Expanded(
  211. child: Text(
  212. locationName,
  213. style: TextStyle(
  214. fontSize: 14.sp,
  215. fontWeight: FontWeight.w500,
  216. color: Get.reactiveTheme.hintColor,
  217. ),
  218. ),
  219. ),
  220. // 显示延迟
  221. if (latency != null && latency > 0)
  222. Text(
  223. '${latency}ms',
  224. style: TextStyle(
  225. fontSize: 12.sp,
  226. color: Get.reactiveTheme.hintColor,
  227. ),
  228. ),
  229. ],
  230. ),
  231. ),
  232. ],
  233. ),
  234. );
  235. }).toList(),
  236. ),
  237. ),
  238. ),
  239. ),
  240. ],
  241. ),
  242. );
  243. }
  244. }