asset_screen.dart 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import '../../../core/l10n/app_localizations.dart';
  4. import '../../../core/theme/app_colors.dart';
  5. import '../../../core/utils/number_format.dart';
  6. import '../../../providers/asset_provider.dart';
  7. import '../../../providers/my_copy_trading_provider.dart';
  8. import '../../widgets/common/app_shimmer.dart';
  9. import '../../widgets/common/app_tab_bar.dart';
  10. import '../../widgets/common/network_error_body.dart';
  11. import 'asset_overview_tab.dart';
  12. import 'asset_futures_tab.dart';
  13. import 'asset_copy_trading_tab.dart';
  14. import 'asset_spot_tab.dart';
  15. import 'asset_spot_trading_tab.dart';
  16. import 'asset_staking_tab.dart';
  17. class AssetScreen extends ConsumerStatefulWidget {
  18. const AssetScreen({super.key, this.initialSubTab});
  19. /// 资产页子 Tab 初始索引(0=总览 … 5=锁仓账户),对齐 Web `?tab=staking`
  20. final int? initialSubTab;
  21. @override
  22. ConsumerState<AssetScreen> createState() => _AssetScreenState();
  23. }
  24. class _AssetScreenState extends ConsumerState<AssetScreen>
  25. with SingleTickerProviderStateMixin {
  26. late TabController _tabController;
  27. late PageController _pageController;
  28. List<String> _getLocalizedTabs(BuildContext context) {
  29. final l10n = AppLocalizations.of(context)!;
  30. return [
  31. l10n.assetOverview,
  32. l10n.fund,
  33. l10n.spotTab,
  34. l10n.futures,
  35. l10n.copyTrading,
  36. l10n.assetTabLocked,
  37. ];
  38. }
  39. int get _initialTabIndex {
  40. final subTab = widget.initialSubTab;
  41. if (subTab != null && subTab >= 0 && subTab < 6) {
  42. return subTab;
  43. }
  44. return 0;
  45. }
  46. @override
  47. void initState() {
  48. super.initState();
  49. final initialIndex = _initialTabIndex;
  50. _tabController = TabController(
  51. length: 6,
  52. vsync: this,
  53. initialIndex: initialIndex,
  54. );
  55. _pageController = PageController(initialPage: initialIndex);
  56. ref.read(currentAssetSubTabProvider.notifier).state = initialIndex;
  57. // 拖动 PageView → 实时更新指示器 offset(平滑插值)
  58. _pageController.addListener(() {
  59. if (!_pageController.hasClients) return;
  60. final page = _pageController.page!;
  61. final offset = page - _tabController.index;
  62. if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
  63. _tabController.offset = offset.clamp(-1.0, 1.0);
  64. }
  65. });
  66. _tabController.addListener(_onTabChanged);
  67. if (initialIndex > 0) {
  68. WidgetsBinding.instance.addPostFrameCallback((_) {
  69. if (!mounted) {
  70. return;
  71. }
  72. _refreshForTab(initialIndex);
  73. });
  74. }
  75. }
  76. @override
  77. void didUpdateWidget(AssetScreen oldWidget) {
  78. super.didUpdateWidget(oldWidget);
  79. final subTab = widget.initialSubTab;
  80. if (subTab == null ||
  81. subTab < 0 ||
  82. subTab >= _tabController.length ||
  83. subTab == oldWidget.initialSubTab) {
  84. return;
  85. }
  86. WidgetsBinding.instance.addPostFrameCallback((_) {
  87. if (!mounted) {
  88. return;
  89. }
  90. _jumpToSubTab(subTab);
  91. });
  92. }
  93. /// 无动画切到指定子 Tab(外部深链进入锁仓账户等)
  94. void _jumpToSubTab(int index) {
  95. if (_tabController.index != index) {
  96. _tabController.index = index;
  97. }
  98. if (_pageController.hasClients) {
  99. _pageController.jumpToPage(index);
  100. }
  101. ref.read(currentAssetSubTabProvider.notifier).state = index;
  102. _refreshForTab(index);
  103. setState(() {});
  104. }
  105. void _onTabChanged() {
  106. if (_tabController.indexIsChanging) {
  107. // Tab 点击 → 驱动 PageView 动画
  108. _pageController.animateToPage(
  109. _tabController.index,
  110. duration: const Duration(milliseconds: 280),
  111. curve: Curves.easeOut,
  112. );
  113. } else {
  114. // Tab 切换完成:更新当前子 tab 索引并按需刷新
  115. final idx = _tabController.index;
  116. final prevIdx = ref.read(currentAssetSubTabProvider);
  117. ref.read(currentAssetSubTabProvider.notifier).state = idx;
  118. if (prevIdx == 2 && idx != 2) {
  119. ref.read(assetProvider.notifier).onSpotTradingTabHidden();
  120. }
  121. WidgetsBinding.instance.addPostFrameCallback((_) {
  122. if (mounted) _refreshForTab(idx);
  123. });
  124. // 仅合约 tab(索引 3)需要定时拉持仓;现货 tab 首次进入 HTTP,后续走 WS
  125. final notifier = ref.read(assetProvider.notifier);
  126. if (idx == 3) {
  127. notifier.startPositionPolling();
  128. } else {
  129. notifier.stopPositionPolling();
  130. }
  131. }
  132. setState(() {});
  133. }
  134. void _refreshForTab(int tabIndex) {
  135. final n = ref.read(assetProvider.notifier);
  136. if (tabIndex == 2) {
  137. n.onSpotTradingTabVisible();
  138. } else {
  139. n.silentRefresh();
  140. }
  141. if (tabIndex == 4) {
  142. // 跟单 tab 额外刷新跟单仓位
  143. ref.read(myCopyTradingProvider.notifier).silentRefresh();
  144. } else if (tabIndex == 5) {
  145. n.loadStakingOrders();
  146. }
  147. }
  148. void _navigateToTab(int index) {
  149. _tabController.animateTo(index);
  150. _pageController.animateToPage(
  151. index,
  152. duration: const Duration(milliseconds: 280),
  153. curve: Curves.easeOut,
  154. );
  155. }
  156. @override
  157. void dispose() {
  158. ref.read(assetProvider.notifier).onSpotTradingTabHidden();
  159. ref.read(assetProvider.notifier).stopPositionPolling();
  160. _tabController.dispose();
  161. _pageController.dispose();
  162. super.dispose();
  163. }
  164. String _getTabLabel(int index) {
  165. const labels = [
  166. 'asset_tab_overview',
  167. 'asset_tab_funds',
  168. 'asset_tab_spot_trading',
  169. 'asset_tab_futures',
  170. 'asset_tab_copy_trading',
  171. 'asset_tab_staking',
  172. ];
  173. return labels[index];
  174. }
  175. @override
  176. Widget build(BuildContext context) {
  177. final state = ref.watch(assetProvider);
  178. final notifier = ref.read(assetProvider.notifier);
  179. final isChineseLocale =
  180. Localizations.localeOf(context).languageCode == 'zh';
  181. final tabs = _getLocalizedTabs(context);
  182. return Scaffold(
  183. body: SafeArea(
  184. child: Column(
  185. children: [
  186. // ── Tab 栏 ─────────────────────────────────────
  187. TabBar(
  188. controller: _tabController,
  189. isScrollable: !isChineseLocale,
  190. indicator: StretchTabIndicator(
  191. controller: _tabController,
  192. color: AppColors.brand,
  193. height: 3,
  194. borderRadius: 1.5,
  195. ),
  196. indicatorSize: TabBarIndicatorSize.label,
  197. labelStyle:
  198. const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
  199. unselectedLabelStyle:
  200. const TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
  201. tabAlignment:
  202. isChineseLocale ? TabAlignment.fill : TabAlignment.start,
  203. dividerColor: Colors.transparent,
  204. labelPadding: isChineseLocale
  205. ? EdgeInsets.zero
  206. : const EdgeInsets.symmetric(horizontal: 14),
  207. padding: isChineseLocale
  208. ? const EdgeInsets.only(top: 8)
  209. : const EdgeInsets.fromLTRB(4, 8, 16, 0),
  210. tabs: <Widget>[
  211. for (int i = 0; i < 6; i++)
  212. Semantics(
  213. label: _getTabLabel(i),
  214. button: true,
  215. child: Tab(text: tabs[i]),
  216. ),
  217. ],
  218. ),
  219. // ── Tab 内容 ───────────────────────────────────
  220. Expanded(
  221. child: _buildContent(state, notifier),
  222. ),
  223. ],
  224. ),
  225. ),
  226. );
  227. }
  228. Widget _buildContent(AssetState state, AssetNotifier notifier) {
  229. if (state.isLoading) return const _AssetShimmer();
  230. if (state.errorMessage != null) {
  231. return NetworkErrorBody(onRetry: notifier.loadAssets);
  232. }
  233. return PageView(
  234. controller: _pageController,
  235. physics: const BouncingScrollPhysics(
  236. parent: AlwaysScrollableScrollPhysics(),
  237. ),
  238. onPageChanged: (index) {
  239. if (_tabController.indexIsChanging) return;
  240. _tabController.index = index;
  241. },
  242. children: [
  243. AssetOverviewTab(
  244. state: state,
  245. notifier: notifier,
  246. onNavigateToStaking: () => _navigateToTab(5),
  247. ),
  248. AssetSpotTab(state: state, notifier: notifier),
  249. AssetSpotTradingTab(state: state, notifier: notifier),
  250. AssetFuturesTab(state: state, notifier: notifier),
  251. AssetCopyTradingTab(state: state, notifier: notifier),
  252. AssetStakingTab(state: state, notifier: notifier),
  253. ],
  254. );
  255. }
  256. }
  257. // ── 资产骨架屏 ──────────────────────────────────────────────
  258. class _AssetShimmer extends StatelessWidget {
  259. const _AssetShimmer();
  260. @override
  261. Widget build(BuildContext context) {
  262. return AppShimmer(
  263. child: SingleChildScrollView(
  264. physics: const NeverScrollableScrollPhysics(),
  265. padding: const EdgeInsets.all(16),
  266. child: Column(
  267. crossAxisAlignment: CrossAxisAlignment.start,
  268. children: [
  269. // 资产估值头部
  270. shimmerBox(80, 13),
  271. const SizedBox(height: 10),
  272. shimmerBox(180, 34),
  273. const SizedBox(height: 6),
  274. shimmerBox(120, 13),
  275. const SizedBox(height: 24),
  276. // 账户卡片列表
  277. ...List.generate(
  278. 3,
  279. (_) => Padding(
  280. padding: const EdgeInsets.only(bottom: 12),
  281. child: Container(
  282. padding: const EdgeInsets.symmetric(
  283. horizontal: 16, vertical: 16),
  284. decoration: BoxDecoration(
  285. color: Colors.white,
  286. borderRadius: BorderRadius.circular(12),
  287. ),
  288. child: Row(
  289. children: [
  290. shimmerCircle(22),
  291. const SizedBox(width: 12),
  292. shimmerBox(80, 14),
  293. const Spacer(),
  294. Column(
  295. crossAxisAlignment: CrossAxisAlignment.end,
  296. children: [
  297. shimmerBox(90, 15),
  298. const SizedBox(height: 4),
  299. shimmerBox(60, 12),
  300. ],
  301. ),
  302. const SizedBox(width: 8),
  303. shimmerBox(18, 18, radius: 4),
  304. ],
  305. ),
  306. ),
  307. )),
  308. const SizedBox(height: 8),
  309. // 持仓卡片
  310. Container(
  311. padding: const EdgeInsets.all(16),
  312. decoration: BoxDecoration(
  313. color: Colors.white,
  314. borderRadius: BorderRadius.circular(12),
  315. ),
  316. child: Column(
  317. children: [
  318. Row(
  319. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  320. children: [shimmerBox(100, 14), shimmerBox(60, 14)],
  321. ),
  322. const SizedBox(height: 12),
  323. Row(
  324. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  325. children:
  326. List.generate(3, (_) => shimmerBox(70, 40, radius: 6)),
  327. ),
  328. ],
  329. ),
  330. ),
  331. ],
  332. ),
  333. ),
  334. );
  335. }
  336. }
  337. // ══════════════════════════════════════════════════════════════
  338. // 共享组件(供各 Tab 文件使用)
  339. // ══════════════════════════════════════════════════════════════
  340. /// 各 Tab 通用的资产估值头部
  341. class AssetHeader extends StatelessWidget {
  342. const AssetHeader({super.key, required this.state, required this.notifier});
  343. final AssetState state;
  344. final AssetNotifier notifier;
  345. @override
  346. Widget build(BuildContext context) {
  347. final cs = Theme.of(context).colorScheme;
  348. final total = state.totalUsdtValue;
  349. final display =
  350. state.obscureBalance ? '******' : formatPrice(total, decimalPlaces: 2);
  351. return Padding(
  352. padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
  353. child: Column(
  354. crossAxisAlignment: CrossAxisAlignment.start,
  355. children: [
  356. Row(
  357. children: [
  358. Text(AppLocalizations.of(context)!.assetValuation,
  359. style: TextStyle(
  360. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  361. const SizedBox(width: 6),
  362. Semantics(
  363. label: 'asset_btn_toggle_balance',
  364. button: true,
  365. enabled: true,
  366. onTap: notifier.toggleObscure,
  367. child: GestureDetector(
  368. onTap: notifier.toggleObscure,
  369. child: Icon(
  370. state.obscureBalance
  371. ? Icons.visibility_off_outlined
  372. : Icons.visibility_outlined,
  373. size: 16,
  374. color: cs.onSurface.withAlpha(153),
  375. ),
  376. ),
  377. ),
  378. ],
  379. ),
  380. const SizedBox(height: 8),
  381. Row(
  382. crossAxisAlignment: CrossAxisAlignment.end,
  383. children: [
  384. Semantics(
  385. label: 'asset_text_total_value',
  386. enabled: true,
  387. child: Text(display,
  388. style: TextStyle(
  389. color: cs.onSurface,
  390. fontSize: 32,
  391. fontWeight: FontWeight.w700,
  392. letterSpacing: -0.5)),
  393. ),
  394. const SizedBox(width: 6),
  395. Semantics(
  396. label: 'asset_text_currency',
  397. enabled: true,
  398. child: Padding(
  399. padding: const EdgeInsets.only(bottom: 5),
  400. child: Text('USDT',
  401. style: TextStyle(
  402. color: cs.onSurface.withAlpha(153), fontSize: 14)),
  403. ),
  404. ),
  405. ],
  406. ),
  407. ],
  408. ),
  409. );
  410. }
  411. }
  412. /// 合约/跟单持仓卡片
  413. class AssetPositionCard extends StatelessWidget {
  414. const AssetPositionCard({
  415. super.key,
  416. required this.obscure,
  417. this.symbol = 'BTCUSDT',
  418. this.isLong = true,
  419. this.positionType = '',
  420. this.leverage = '20X',
  421. this.pnl = '+24.35',
  422. this.pnlColor,
  423. this.roi = '+5.36%',
  424. this.rows = const [],
  425. this.onShare,
  426. });
  427. final bool obscure;
  428. final String symbol;
  429. final bool isLong;
  430. final String positionType;
  431. final String leverage;
  432. final String pnl;
  433. final Color? pnlColor;
  434. final String roi;
  435. final List<List<String>> rows;
  436. final VoidCallback? onShare;
  437. @override
  438. Widget build(BuildContext context) {
  439. final cs = Theme.of(context).colorScheme;
  440. final isDark = Theme.of(context).brightness == Brightness.dark;
  441. final sideColor = isLong ? AppColors.rise : AppColors.fall;
  442. final effectivePnlColor = pnlColor ?? AppColors.rise;
  443. return Container(
  444. margin: const EdgeInsets.symmetric(horizontal: 16),
  445. padding: const EdgeInsets.all(16),
  446. decoration: BoxDecoration(
  447. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  448. borderRadius: BorderRadius.circular(12),
  449. border: isDark
  450. ? null
  451. : Border.all(color: AppColors.lightBorder, width: 0.5),
  452. ),
  453. child: Column(
  454. crossAxisAlignment: CrossAxisAlignment.start,
  455. children: [
  456. // 头部:币种 + 标签
  457. Row(
  458. children: [
  459. Container(
  460. width: 32,
  461. height: 32,
  462. decoration: BoxDecoration(
  463. color: sideColor, borderRadius: BorderRadius.circular(8)),
  464. child: Center(
  465. child: Text(
  466. isLong
  467. ? AppLocalizations.of(context)!.longLabel
  468. : AppLocalizations.of(context)!.shortLabel,
  469. style: const TextStyle(
  470. color: Colors.white,
  471. fontSize: 13,
  472. fontWeight: FontWeight.w700)),
  473. ),
  474. ),
  475. const SizedBox(width: 10),
  476. Text(symbol,
  477. style: TextStyle(
  478. color: cs.onSurface,
  479. fontSize: 15,
  480. fontWeight: FontWeight.w600)),
  481. const SizedBox(width: 8),
  482. _Tag(label: AppLocalizations.of(context)!.perpetual),
  483. const SizedBox(width: 4),
  484. _Tag(label: positionType),
  485. const SizedBox(width: 4),
  486. Container(
  487. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  488. decoration: BoxDecoration(
  489. color: cs.onSurface.withAlpha(20),
  490. borderRadius: BorderRadius.circular(4),
  491. ),
  492. child: Text(leverage,
  493. style: TextStyle(
  494. color: cs.onSurface,
  495. fontSize: 11,
  496. fontWeight: FontWeight.w600)),
  497. ),
  498. const Spacer(),
  499. GestureDetector(
  500. onTap: onShare,
  501. child: Icon(Icons.share_outlined,
  502. size: 18, color: cs.onSurface.withAlpha(153)),
  503. ),
  504. ],
  505. ),
  506. const SizedBox(height: 14),
  507. // 未实现盈亏 + 收益率
  508. Container(
  509. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
  510. decoration: const BoxDecoration(),
  511. child: Row(
  512. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  513. children: [
  514. Column(
  515. crossAxisAlignment: CrossAxisAlignment.start,
  516. children: [
  517. Text(
  518. '${AppLocalizations.of(context)!.unrealizedPnl} (USDT)',
  519. style: TextStyle(
  520. color: cs.onSurface.withAlpha(153), fontSize: 11)),
  521. const SizedBox(height: 2),
  522. Text(
  523. obscure ? '******' : pnl,
  524. style: TextStyle(
  525. color: effectivePnlColor,
  526. fontSize: 20,
  527. fontWeight: FontWeight.w700),
  528. ),
  529. ],
  530. ),
  531. Column(
  532. crossAxisAlignment: CrossAxisAlignment.end,
  533. children: [
  534. Text(AppLocalizations.of(context)!.returnRate,
  535. style: TextStyle(
  536. color: cs.onSurface.withAlpha(153), fontSize: 11)),
  537. const SizedBox(height: 2),
  538. Text(roi,
  539. style: TextStyle(
  540. color: effectivePnlColor,
  541. fontSize: 16,
  542. fontWeight: FontWeight.w600)),
  543. ],
  544. ),
  545. ],
  546. ),
  547. ),
  548. const SizedBox(height: 14),
  549. // 数据网格
  550. _DataGrid(obscure: obscure, rows: rows),
  551. ],
  552. ),
  553. );
  554. }
  555. }
  556. /// 账户行
  557. class AssetAccountRow extends StatelessWidget {
  558. const AssetAccountRow({
  559. super.key,
  560. required this.icon,
  561. required this.label,
  562. required this.amount,
  563. required this.usdAmount,
  564. this.onTransfer,
  565. this.onTap,
  566. });
  567. final IconData icon;
  568. final String label;
  569. final String amount;
  570. final String usdAmount;
  571. final VoidCallback? onTransfer;
  572. final VoidCallback? onTap;
  573. @override
  574. Widget build(BuildContext context) {
  575. final cs = Theme.of(context).colorScheme;
  576. final isDark = Theme.of(context).brightness == Brightness.dark;
  577. return GestureDetector(
  578. onTap: onTap,
  579. behavior: HitTestBehavior.opaque,
  580. child: Container(
  581. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  582. decoration: BoxDecoration(
  583. color:
  584. isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  585. borderRadius: BorderRadius.circular(12),
  586. border: isDark
  587. ? null
  588. : Border.all(color: AppColors.lightBorder, width: 0.5),
  589. ),
  590. child: Row(
  591. children: [
  592. Icon(icon, color: cs.onSurface.withAlpha(153), size: 22),
  593. const SizedBox(width: 12),
  594. Expanded(
  595. child: Text(label,
  596. style: TextStyle(
  597. color: cs.onSurface,
  598. fontSize: 14,
  599. fontWeight: FontWeight.w500)),
  600. ),
  601. if (onTransfer != null) ...[
  602. OutlinedButton(
  603. onPressed: onTransfer,
  604. style: OutlinedButton.styleFrom(
  605. side: const BorderSide(color: AppColors.brand),
  606. foregroundColor: AppColors.brand,
  607. minimumSize: const Size(0, 32),
  608. padding: const EdgeInsets.symmetric(horizontal: 12),
  609. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  610. ),
  611. child: Text(
  612. AppLocalizations.of(context)!.transfer,
  613. style: const TextStyle(fontSize: 12),
  614. ),
  615. ),
  616. const SizedBox(width: 12),
  617. ],
  618. Column(
  619. crossAxisAlignment: CrossAxisAlignment.end,
  620. children: [
  621. Text(amount,
  622. style: TextStyle(
  623. color: cs.onSurface,
  624. fontSize: 15,
  625. fontWeight: FontWeight.w600)),
  626. if (usdAmount.isNotEmpty)
  627. Text(usdAmount,
  628. style: TextStyle(
  629. color: cs.onSurface.withAlpha(120), fontSize: 12)),
  630. ],
  631. ),
  632. const SizedBox(width: 8),
  633. Icon(
  634. Icons.chevron_right,
  635. size: 18,
  636. color: cs.onSurface.withAlpha(102),
  637. ),
  638. ],
  639. ),
  640. ),
  641. );
  642. }
  643. }
  644. // ── 内部私有组件 ──────────────────────────────────────────
  645. class _Tag extends StatelessWidget {
  646. const _Tag({required this.label});
  647. final String label;
  648. @override
  649. Widget build(BuildContext context) {
  650. final cs = Theme.of(context).colorScheme;
  651. return Container(
  652. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  653. decoration: BoxDecoration(
  654. border: Border.all(color: cs.onSurface.withAlpha(60)),
  655. borderRadius: BorderRadius.circular(4),
  656. ),
  657. child: Text(label,
  658. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 10)),
  659. );
  660. }
  661. }
  662. class _DataGrid extends StatelessWidget {
  663. const _DataGrid({required this.obscure, required this.rows});
  664. final bool obscure;
  665. final List<List<String>> rows;
  666. @override
  667. Widget build(BuildContext context) {
  668. final cs = Theme.of(context).colorScheme;
  669. return Column(
  670. children: rows.map((row) {
  671. return Padding(
  672. padding: const EdgeInsets.only(bottom: 12),
  673. child: Row(
  674. children: [
  675. for (int i = 0; i < row.length; i += 2) ...[
  676. Expanded(
  677. child: Column(
  678. crossAxisAlignment: i == 0
  679. ? CrossAxisAlignment.start
  680. : i + 2 >= row.length
  681. ? CrossAxisAlignment.end
  682. : CrossAxisAlignment.center,
  683. children: [
  684. Text(row[i],
  685. style: TextStyle(
  686. color: cs.onSurface.withAlpha(120), fontSize: 11),
  687. textAlign: i == 0
  688. ? TextAlign.left
  689. : i + 2 >= row.length
  690. ? TextAlign.right
  691. : TextAlign.center),
  692. const SizedBox(height: 2),
  693. Text(
  694. obscure ? '***' : row[i + 1],
  695. style: TextStyle(
  696. color: cs.onSurface,
  697. fontSize: 13,
  698. fontWeight: FontWeight.w500),
  699. textAlign: i == 0
  700. ? TextAlign.left
  701. : i + 2 >= row.length
  702. ? TextAlign.right
  703. : TextAlign.center,
  704. ),
  705. ],
  706. ),
  707. ),
  708. ],
  709. ],
  710. ),
  711. );
  712. }).toList(),
  713. );
  714. }
  715. }