asset_history_screen.dart 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143
  1. import 'dart:math' as math;
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:intl/intl.dart';
  6. import '../../../core/l10n/app_localizations.dart';
  7. import '../../../core/theme/app_colors.dart';
  8. import '../../../core/utils/top_toast.dart';
  9. import '../../../data/models/asset/asset_statement.dart';
  10. import '../../../providers/statement_provider.dart';
  11. import '../../widgets/common/app_refresh_indicator.dart';
  12. class AssetHistoryScreen extends ConsumerWidget {
  13. const AssetHistoryScreen({super.key});
  14. @override
  15. Widget build(BuildContext context, WidgetRef ref) {
  16. final state = ref.watch(statementProvider);
  17. final notifier = ref.read(statementProvider.notifier);
  18. final filterCache = ref.watch(statementFilterCacheProvider);
  19. ref.listen<StatementState>(statementProvider, (prev, next) {
  20. if (next.errorMessage != null && next.errorMessage != prev?.errorMessage) {
  21. final l10n = AppLocalizations.of(context)!;
  22. showTopToast(context,
  23. message: resolveProviderError(next.errorMessage!, l10n) ?? next.errorMessage!,
  24. backgroundColor: AppColors.fall);
  25. }
  26. });
  27. return Scaffold(
  28. appBar: AppBar(
  29. leading: IconButton(
  30. icon: const Icon(Icons.chevron_left, size: 28),
  31. onPressed: () => context.pop(),
  32. ),
  33. title: Text(AppLocalizations.of(context)!.fundRecord,
  34. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
  35. centerTitle: true,
  36. ),
  37. body: Column(
  38. children: [
  39. _FilterSection(
  40. state: state,
  41. notifier: notifier,
  42. coins: filterCache.coins,
  43. types: filterCache.types,
  44. ),
  45. Expanded(
  46. child: _buildList(context, state, notifier, filterCache.types),
  47. ),
  48. ],
  49. ),
  50. );
  51. }
  52. Widget _buildList(
  53. BuildContext context,
  54. StatementState state,
  55. StatementNotifier notifier,
  56. List<StatementType> types,
  57. ) {
  58. final cs = Theme.of(context).colorScheme;
  59. if (state.isLoading && state.records.isEmpty) {
  60. return const Center(child: CircularProgressIndicator());
  61. }
  62. if (state.records.isEmpty) {
  63. return Center(
  64. child: Text(AppLocalizations.of(context)!.noRecord,
  65. style:
  66. TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 15)),
  67. );
  68. }
  69. return AppRefreshIndicator(
  70. onRefresh: notifier.refresh,
  71. child: NotificationListener<ScrollNotification>(
  72. onNotification: (notification) {
  73. if (notification is ScrollEndNotification &&
  74. notification.metrics.pixels >=
  75. notification.metrics.maxScrollExtent - 100) {
  76. notifier.loadMore();
  77. }
  78. return false;
  79. },
  80. child: ListView.separated(
  81. padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
  82. itemCount: state.records.length + (state.hasMore ? 1 : 0),
  83. separatorBuilder: (_, __) => const SizedBox(height: 10),
  84. itemBuilder: (_, i) {
  85. if (i >= state.records.length) {
  86. return const Padding(
  87. padding: EdgeInsets.symmetric(vertical: 16),
  88. child:
  89. Center(child: CircularProgressIndicator(strokeWidth: 2)),
  90. );
  91. }
  92. return _RecordCard(
  93. record: state.records[i],
  94. types: types,
  95. coinCode: state.selectedCoinCode,
  96. );
  97. },
  98. ),
  99. ),
  100. );
  101. }
  102. }
  103. Map<String, String> _buildLocalTypeNames(AppLocalizations l10n) => {
  104. '0': l10n.txType0, '1': l10n.txType1, '2': l10n.txType2, '3': l10n.txType3, '4': l10n.txType4,
  105. '5': l10n.txType5, '6': l10n.txType6, '7': l10n.txType7, '8': l10n.txType8, '9': l10n.txType9,
  106. '10': l10n.txType10, '11': l10n.txType11, '13': l10n.txType13, '14': l10n.txType14,
  107. '15': l10n.txType15, '16': l10n.txType16, '17': l10n.txType17, '18': l10n.txType18,
  108. '19': l10n.txType19, '20': l10n.txType20, '21': l10n.txType21, '22': l10n.txType22,
  109. '23': l10n.txType23, '24': l10n.txType24, '25': l10n.txType25, '26': l10n.txType26,
  110. '27': l10n.txType27, '28': l10n.txType28, '29': l10n.txType29,
  111. '30': l10n.txType30, '31': l10n.txType31, '32': l10n.txType32, '33': l10n.txType33,
  112. '34': l10n.txType34, '35': l10n.txType35, '36': l10n.txType36,
  113. '37': l10n.txType37, '38': l10n.txType38, '39': l10n.txType39,
  114. '40': l10n.txType40, '41': l10n.txType41, '42': l10n.txType42,
  115. '43': l10n.txType43, '44': l10n.txType44, '45': l10n.txType45, '46': l10n.txType46,
  116. '47': l10n.txType47, '48': l10n.txType48, '49': l10n.txType49, '50': l10n.txType50, '51': l10n.txType51,
  117. };
  118. // ══════════════════════════════════════════════════════════════
  119. // 筛选区
  120. // ══════════════════════════════════════════════════════════════
  121. class _FilterSection extends StatelessWidget {
  122. const _FilterSection({
  123. required this.state,
  124. required this.notifier,
  125. required this.coins,
  126. required this.types,
  127. });
  128. final StatementState state;
  129. final StatementNotifier notifier;
  130. final List<StatementCoin> coins;
  131. final List<StatementType> types;
  132. @override
  133. Widget build(BuildContext context) {
  134. final cs = Theme.of(context).colorScheme;
  135. final isDark = Theme.of(context).brightness == Brightness.dark;
  136. final dateFmt = DateFormat('yyyy-MM-dd');
  137. final l10n = AppLocalizations.of(context)!;
  138. // 构建选项列表:(value, label)
  139. final coinOptions = <(String, String)>[
  140. ('', l10n.all),
  141. ...coins.map((c) => (c.code, c.code)),
  142. ];
  143. final localTypeNames = _buildLocalTypeNames(l10n);
  144. final typeOptions = <(String, String)>[
  145. ('', l10n.all),
  146. ...types.map((t) => (t.typeId, localTypeNames[t.typeId] ?? t.name)),
  147. ];
  148. final coinLabel = coinOptions
  149. .firstWhere((e) => e.$1 == state.selectedCoinCode,
  150. orElse: () => ('', l10n.all))
  151. .$2;
  152. final typeLabel = typeOptions
  153. .firstWhere((e) => e.$1 == state.selectedTypeId,
  154. orElse: () => ('', l10n.all))
  155. .$2;
  156. return Padding(
  157. padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  158. child: Column(
  159. crossAxisAlignment: CrossAxisAlignment.start,
  160. children: [
  161. // 币种 + 类型
  162. Row(
  163. children: [
  164. Expanded(
  165. child: Column(
  166. crossAxisAlignment: CrossAxisAlignment.stretch,
  167. children: [
  168. Text(l10n.coinLabel,
  169. style: TextStyle(
  170. color: cs.onSurface.withAlpha(120), fontSize: 12)),
  171. const SizedBox(height: 6),
  172. _PickerField(
  173. label: coinLabel,
  174. items: coinOptions,
  175. value: state.selectedCoinCode,
  176. title: l10n.selectCoin,
  177. onChanged: (v) => notifier.selectCoin(v),
  178. ),
  179. ],
  180. ),
  181. ),
  182. const SizedBox(width: 12),
  183. Expanded(
  184. child: Column(
  185. crossAxisAlignment: CrossAxisAlignment.stretch,
  186. children: [
  187. Text(l10n.typeLabel,
  188. style: TextStyle(
  189. color: cs.onSurface.withAlpha(120), fontSize: 12)),
  190. const SizedBox(height: 6),
  191. _PickerField(
  192. label: typeLabel,
  193. items: typeOptions,
  194. value: state.selectedTypeId,
  195. title: l10n.selectType,
  196. onChanged: (v) => notifier.selectType(v),
  197. ),
  198. ],
  199. ),
  200. ),
  201. ],
  202. ),
  203. const SizedBox(height: 12),
  204. // 起止时间 + 重置/搜索
  205. Text(l10n.timeRange,
  206. style: TextStyle(
  207. color: cs.onSurface.withAlpha(120), fontSize: 12)),
  208. const SizedBox(height: 6),
  209. Row(
  210. children: [
  211. Expanded(
  212. child: Container(
  213. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
  214. decoration: BoxDecoration(
  215. color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary,
  216. border: Border.all(
  217. color: isDark
  218. ? AppColors.darkCardBorder.withAlpha(180)
  219. : AppColors.lightBorder),
  220. borderRadius: BorderRadius.circular(8),
  221. ),
  222. child: Row(
  223. children: [
  224. Flexible(
  225. child: _TimeChip(
  226. label: state.startDate != null
  227. ? dateFmt.format(state.startDate!)
  228. : l10n.startTime,
  229. onTap: () async {
  230. final date = await _showDrumDatePicker(
  231. context: context,
  232. initialDate: state.startDate ?? DateTime.now(),
  233. firstDate: DateTime(2017),
  234. lastDate: DateTime.now(),
  235. title: l10n.startTime,
  236. );
  237. if (date != null) notifier.selectStartDate(date);
  238. },
  239. ),
  240. ),
  241. Text('~',
  242. style: TextStyle(
  243. color: cs.onSurface.withAlpha(120))),
  244. Flexible(
  245. child: _TimeChip(
  246. label: state.endDate != null
  247. ? dateFmt.format(state.endDate!)
  248. : l10n.endTime,
  249. onTap: () async {
  250. final date = await _showDrumDatePicker(
  251. context: context,
  252. initialDate: state.endDate ?? DateTime.now(),
  253. firstDate: DateTime(2017),
  254. lastDate: DateTime.now(),
  255. title: l10n.endTime,
  256. );
  257. if (date != null) notifier.selectEndDate(date);
  258. },
  259. ),
  260. ),
  261. ],
  262. ),
  263. ),
  264. ),
  265. const SizedBox(width: 8),
  266. GestureDetector(
  267. onTap: notifier.reset,
  268. child: Container(
  269. padding:
  270. const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
  271. decoration: BoxDecoration(
  272. border: Border.all(
  273. color: isDark
  274. ? AppColors.darkCardBorder.withAlpha(180)
  275. : AppColors.lightBorder),
  276. borderRadius: BorderRadius.circular(8),
  277. ),
  278. child: Text(l10n.reset,
  279. style:
  280. TextStyle(color: cs.onSurface, fontSize: 13)),
  281. ),
  282. ),
  283. const SizedBox(width: 8),
  284. GestureDetector(
  285. onTap: notifier.search,
  286. child: Container(
  287. padding:
  288. const EdgeInsets.symmetric(horizontal: 20, vertical: 9),
  289. decoration: BoxDecoration(
  290. color: AppColors.brand,
  291. borderRadius: BorderRadius.circular(8),
  292. ),
  293. child: Text(l10n.search,
  294. style: const TextStyle(
  295. color: Colors.black,
  296. fontSize: 13,
  297. fontWeight: FontWeight.w600)),
  298. ),
  299. ),
  300. ],
  301. ),
  302. ],
  303. ),
  304. );
  305. }
  306. }
  307. // ══════════════════════════════════════════════════════════════
  308. // 自定义选择器字段(替代 DropdownButtonFormField)
  309. // ══════════════════════════════════════════════════════════════
  310. class _PickerField extends StatelessWidget {
  311. const _PickerField({
  312. required this.label,
  313. required this.items,
  314. required this.value,
  315. required this.title,
  316. required this.onChanged,
  317. });
  318. final String label;
  319. final List<(String, String)> items; // (value, displayLabel)
  320. final String value;
  321. final String title;
  322. final ValueChanged<String> onChanged;
  323. @override
  324. Widget build(BuildContext context) {
  325. final cs = Theme.of(context).colorScheme;
  326. final isDark = Theme.of(context).brightness == Brightness.dark;
  327. final fieldBg = isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary;
  328. final borderColor = isDark ? AppColors.darkCardBorder.withAlpha(180) : AppColors.lightBorder;
  329. return LayoutBuilder(
  330. builder: (_, constraints) => GestureDetector(
  331. onTap: () => _showSheet(context),
  332. child: Container(
  333. width: constraints.maxWidth,
  334. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
  335. decoration: BoxDecoration(
  336. color: fieldBg,
  337. border: Border.all(color: borderColor),
  338. borderRadius: BorderRadius.circular(8),
  339. ),
  340. child: Row(
  341. children: [
  342. Expanded(
  343. child: Text(
  344. label,
  345. style: TextStyle(color: cs.onSurface, fontSize: 14),
  346. overflow: TextOverflow.ellipsis,
  347. ),
  348. ),
  349. Icon(Icons.keyboard_arrow_down,
  350. size: 18, color: cs.onSurface.withAlpha(120)),
  351. ],
  352. ),
  353. ),
  354. ),
  355. );
  356. }
  357. void _showSheet(BuildContext context) {
  358. final cs = Theme.of(context).colorScheme;
  359. final isDark = Theme.of(context).brightness == Brightness.dark;
  360. showModalBottomSheet<void>(
  361. context: context,
  362. useRootNavigator: true,
  363. backgroundColor:
  364. isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  365. shape: const RoundedRectangleBorder(
  366. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  367. ),
  368. builder: (ctx) {
  369. final mq = MediaQuery.of(ctx);
  370. return LayoutBuilder(
  371. builder: (ctx, constraints) {
  372. // 把手 + 标题 + 分隔线 + 底安全区,与下方列表分离避免超出 BottomSheet 高度
  373. const topReserve = 100.0;
  374. final bottomGap = mq.padding.bottom + 8;
  375. final parentH = constraints.maxHeight;
  376. final listMax = parentH.isFinite
  377. ? math.min(
  378. mq.size.height * 0.45,
  379. math.max(120.0, parentH - topReserve - bottomGap),
  380. )
  381. : mq.size.height * 0.45;
  382. return Column(
  383. mainAxisSize: MainAxisSize.min,
  384. children: [
  385. Padding(
  386. padding: const EdgeInsets.only(top: 12, bottom: 4),
  387. child: Container(
  388. width: 36,
  389. height: 4,
  390. decoration: BoxDecoration(
  391. color: cs.onSurface.withAlpha(40),
  392. borderRadius: BorderRadius.circular(2),
  393. ),
  394. ),
  395. ),
  396. Padding(
  397. padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
  398. child: Text(title,
  399. style: TextStyle(
  400. color: cs.onSurface,
  401. fontSize: 15,
  402. fontWeight: FontWeight.w600)),
  403. ),
  404. Divider(height: 1, color: cs.outline.withAlpha(40)),
  405. ConstrainedBox(
  406. constraints: BoxConstraints(maxHeight: listMax),
  407. child: ListView.separated(
  408. shrinkWrap: true,
  409. itemCount: items.length,
  410. separatorBuilder: (_, __) =>
  411. Divider(height: 1, color: cs.outline.withAlpha(25)),
  412. itemBuilder: (_, i) {
  413. final (val, lbl) = items[i];
  414. final isSelected = val == value;
  415. return GestureDetector(
  416. onTap: () {
  417. Navigator.pop(ctx);
  418. onChanged(val);
  419. },
  420. child: Padding(
  421. padding: const EdgeInsets.symmetric(
  422. horizontal: 20, vertical: 14),
  423. child: Row(
  424. children: [
  425. Expanded(
  426. child: Text(
  427. lbl,
  428. style: TextStyle(
  429. color: isSelected
  430. ? AppColors.brand
  431. : cs.onSurface,
  432. fontSize: 15,
  433. fontWeight: isSelected
  434. ? FontWeight.w600
  435. : FontWeight.w400,
  436. ),
  437. ),
  438. ),
  439. if (isSelected)
  440. const Icon(Icons.check,
  441. size: 18, color: AppColors.brand),
  442. ],
  443. ),
  444. ),
  445. );
  446. },
  447. ),
  448. ),
  449. SizedBox(height: bottomGap),
  450. ],
  451. );
  452. },
  453. );
  454. },
  455. );
  456. }
  457. }
  458. // ══════════════════════════════════════════════════════════════
  459. // 流水记录卡片
  460. // ══════════════════════════════════════════════════════════════
  461. /// 类别行展示:去掉服务端固定的中文「永续」或英文 perpetual,改为 [AppLocalizations.perpetual]。
  462. String localizedStatementCategorySymbol(String raw, AppLocalizations l10n) {
  463. final s = raw.trim();
  464. if (s.isEmpty) {
  465. return s;
  466. }
  467. final zh = RegExp(r'\s*永续\s*$');
  468. if (zh.hasMatch(s)) {
  469. final base = s.replaceFirst(zh, '').trim();
  470. if (base.isEmpty) {
  471. return l10n.perpetual;
  472. }
  473. return '$base ${l10n.perpetual}';
  474. }
  475. final en = RegExp(r'\s+perpetual\s*$', caseSensitive: false);
  476. if (en.hasMatch(s)) {
  477. final base = s.replaceFirst(en, '').trim();
  478. if (base.isEmpty) {
  479. return l10n.perpetual;
  480. }
  481. return '$base ${l10n.perpetual}';
  482. }
  483. return s;
  484. }
  485. /// 现货成交(type=48)类别不与永续挂钩:去掉尾部永续表述,仅保留标的(与 Web 一致)。
  486. String localizedStatementCategoryForRecord(
  487. AssetStatement record,
  488. AppLocalizations l10n,
  489. ) {
  490. if (record.type == '48') {
  491. return _stripPerpetualSuffixRaw(record.displaySymbol);
  492. }
  493. return localizedStatementCategorySymbol(record.displaySymbol, l10n);
  494. }
  495. String _stripPerpetualSuffixRaw(String raw) {
  496. final s = raw.trim();
  497. if (s.isEmpty) {
  498. return s;
  499. }
  500. var t = s.replaceFirst(RegExp(r'\s*永续\s*$'), '').trim();
  501. t = t.replaceFirst(RegExp(r'\s+perpetual\s*$', caseSensitive: false), '').trim();
  502. t = t.replaceFirst(RegExp(r'\s*無期限\s*$'), '').trim();
  503. if (t.isEmpty) {
  504. return s.split(RegExp(r'\s+')).first;
  505. }
  506. return t;
  507. }
  508. class _RecordCard extends StatelessWidget {
  509. const _RecordCard({
  510. required this.record,
  511. required this.types,
  512. this.coinCode = '',
  513. });
  514. final AssetStatement record;
  515. final List<StatementType> types;
  516. final String coinCode;
  517. String _localizedExtraTitle(AppLocalizations l10n) {
  518. switch (record.type) {
  519. case '1': return l10n.feeUsdt;
  520. case '19': return l10n.openCloseLabel;
  521. case '25': return l10n.rebateId;
  522. default: return '';
  523. }
  524. }
  525. String _localizedExtraValue(AppLocalizations l10n) {
  526. switch (record.type) {
  527. case '1': return record.fee;
  528. case '19': return record.direction == '0' ? l10n.openPosition : l10n.closePosition;
  529. case '25': return record.rebateId;
  530. default: return '';
  531. }
  532. }
  533. @override
  534. Widget build(BuildContext context) {
  535. final cs = Theme.of(context).colorScheme;
  536. final isDark = Theme.of(context).brightness == Brightness.dark;
  537. final amount = double.tryParse(record.amount) ?? 0;
  538. final isPositive = amount >= 0;
  539. final amountColor = isPositive ? AppColors.rise : AppColors.fall;
  540. final amountStr =
  541. isPositive ? '+${_formatAmount(amount)}' : _formatAmount(amount);
  542. final l10n = AppLocalizations.of(context)!;
  543. final typeName = _findTypeName(record.type, l10n);
  544. final coin = coinCode.isNotEmpty ? coinCode : 'USDT';
  545. // 数量标签用记录本身的币种(取 symbol 第一个词,如 "BTC 永续" → "BTC")
  546. final amountCoin = record.symbol.isNotEmpty
  547. ? record.symbol.split(' ').first
  548. : coin;
  549. final timeParts = record.createTime.split(' ');
  550. final datePart = timeParts.isNotEmpty ? timeParts[0] : record.createTime;
  551. final timePart = timeParts.length > 1 ? timeParts[1] : '';
  552. final hasExtra = record.showExtraField || record.symbol.isNotEmpty;
  553. // 颜色分层:header背景 vs body背景
  554. final headerBg =
  555. isDark ? AppColors.brand.withAlpha(90) : AppColors.tagIndigoBgLight;
  556. final bodyBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
  557. final borderColor =
  558. isDark ? AppColors.darkCardBorder : AppColors.lightBorder;
  559. return Container(
  560. clipBehavior: Clip.antiAlias,
  561. decoration: BoxDecoration(
  562. borderRadius: BorderRadius.circular(12),
  563. border: Border.all(color: borderColor, width: 0.5),
  564. color: bodyBg, // 封住底部 + 为 Divider 提供背景色
  565. ),
  566. child: Column(
  567. children: [
  568. // ── Header:币种 + 已完成(带背景色)──────────
  569. Container(
  570. color: headerBg,
  571. padding:
  572. const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
  573. child: Row(
  574. children: [
  575. Text(
  576. 'USDT',
  577. style: TextStyle(
  578. color: cs.onSurface,
  579. fontSize: 14,
  580. fontWeight: FontWeight.w600,
  581. ),
  582. ),
  583. const Spacer(),
  584. Text(
  585. AppLocalizations.of(context)!.completed,
  586. style: TextStyle(
  587. color: cs.onSurface.withAlpha(100),
  588. fontSize: 12,
  589. ),
  590. ),
  591. ],
  592. ),
  593. ),
  594. // ── Body ────────────────────────────────────
  595. Padding(
  596. padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
  597. child: Row(
  598. crossAxisAlignment: CrossAxisAlignment.start,
  599. children: [
  600. // 类型
  601. Expanded(
  602. flex: 4,
  603. child: Column(
  604. crossAxisAlignment: CrossAxisAlignment.start,
  605. children: [
  606. Text(AppLocalizations.of(context)!.typeLabel,
  607. style: TextStyle(
  608. color: cs.onSurface.withAlpha(100),
  609. fontSize: 11)),
  610. const SizedBox(height: 5),
  611. Text(
  612. typeName,
  613. style: TextStyle(
  614. color: cs.onSurface,
  615. fontSize: 13,
  616. fontWeight: FontWeight.w500,
  617. ),
  618. ),
  619. ],
  620. ),
  621. ),
  622. // 时间
  623. Expanded(
  624. flex: 4,
  625. child: Column(
  626. crossAxisAlignment: CrossAxisAlignment.start,
  627. children: [
  628. Text(AppLocalizations.of(context)!.timeLabel,
  629. style: TextStyle(
  630. color: cs.onSurface.withAlpha(100),
  631. fontSize: 11)),
  632. const SizedBox(height: 5),
  633. Text(datePart,
  634. style: TextStyle(
  635. color: cs.onSurface, fontSize: 12)),
  636. if (timePart.isNotEmpty)
  637. Text(timePart,
  638. style: TextStyle(
  639. color: cs.onSurface.withAlpha(153),
  640. fontSize: 12)),
  641. ],
  642. ),
  643. ),
  644. // 数量(右对齐)
  645. Expanded(
  646. flex: 4,
  647. child: Column(
  648. crossAxisAlignment: CrossAxisAlignment.end,
  649. children: [
  650. Text('${AppLocalizations.of(context)!.amountLabel}($amountCoin)',
  651. overflow: TextOverflow.ellipsis,
  652. style: TextStyle(
  653. color: cs.onSurface.withAlpha(100),
  654. fontSize: 11)),
  655. const SizedBox(height: 5),
  656. Text(
  657. amountStr,
  658. overflow: TextOverflow.ellipsis,
  659. style: TextStyle(
  660. color: amountColor,
  661. fontSize: 14,
  662. fontWeight: FontWeight.w600,
  663. fontFeatures: const [FontFeature.tabularFigures()],
  664. ),
  665. ),
  666. ],
  667. ),
  668. ),
  669. ],
  670. ),
  671. ),
  672. // ── 额外字段行(类别 / 开平仓)──────────────
  673. if (hasExtra) ...[
  674. Divider(
  675. height: 1,
  676. thickness: 0.5,
  677. color: borderColor,
  678. indent: 14,
  679. endIndent: 14,
  680. ),
  681. Padding(
  682. padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
  683. child: Row(
  684. children: [
  685. if (record.symbol.isNotEmpty)
  686. Expanded(
  687. child: Column(
  688. crossAxisAlignment: CrossAxisAlignment.start,
  689. children: [
  690. Text(AppLocalizations.of(context)!.categoryLabel,
  691. style: TextStyle(
  692. color: cs.onSurface.withAlpha(100),
  693. fontSize: 11)),
  694. const SizedBox(height: 5),
  695. Text(localizedStatementCategoryForRecord(record, l10n),
  696. style: TextStyle(
  697. color: cs.onSurface, fontSize: 13)),
  698. ],
  699. ),
  700. ),
  701. if (record.showExtraField)
  702. Expanded(
  703. child: Column(
  704. crossAxisAlignment: CrossAxisAlignment.start,
  705. children: [
  706. Text(_localizedExtraTitle(l10n),
  707. style: TextStyle(
  708. color: cs.onSurface.withAlpha(100),
  709. fontSize: 11)),
  710. const SizedBox(height: 5),
  711. Text(_localizedExtraValue(l10n),
  712. style: TextStyle(
  713. color: cs.onSurface, fontSize: 13)),
  714. ],
  715. ),
  716. ),
  717. ],
  718. ),
  719. ),
  720. ],
  721. ],
  722. ),
  723. );
  724. }
  725. Map<String, String> _localTypeNames(AppLocalizations l10n) => _buildLocalTypeNames(l10n);
  726. String _findTypeName(String typeId, AppLocalizations l10n) {
  727. final localName = _localTypeNames(l10n)[typeId];
  728. if (localName != null) return localName;
  729. for (final t in types) {
  730. if (t.typeId == typeId) return t.name;
  731. }
  732. return typeId;
  733. }
  734. String _formatAmount(double value) {
  735. final abs = value.abs();
  736. int decimals;
  737. if (abs < 0.0001) {
  738. decimals = 8;
  739. } else if (abs < 0.001) {
  740. decimals = 7;
  741. } else if (abs < 0.01) {
  742. decimals = 6;
  743. } else if (abs < 0.1) {
  744. decimals = 5;
  745. } else if (abs < 1) {
  746. decimals = 4;
  747. } else {
  748. decimals = 2;
  749. }
  750. final factor = _pow10(decimals);
  751. final truncated = (value * factor).truncateToDouble() / factor;
  752. var str = truncated.toStringAsFixed(decimals);
  753. if (str.contains('.')) {
  754. str = str.replaceAll(RegExp(r'0+$'), '');
  755. str = str.replaceAll(RegExp(r'\.$'), '');
  756. }
  757. return str;
  758. }
  759. double _pow10(int n) {
  760. double result = 1;
  761. for (int i = 0; i < n; i++) result *= 10;
  762. return result;
  763. }
  764. }
  765. // ══════════════════════════════════════════════════════════════
  766. // 时间选择 Chip
  767. // ══════════════════════════════════════════════════════════════
  768. class _TimeChip extends StatelessWidget {
  769. const _TimeChip({required this.label, required this.onTap});
  770. final String label;
  771. final VoidCallback onTap;
  772. @override
  773. Widget build(BuildContext context) {
  774. final cs = Theme.of(context).colorScheme;
  775. return GestureDetector(
  776. onTap: onTap,
  777. child: Padding(
  778. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 5),
  779. child: Row(
  780. mainAxisSize: MainAxisSize.min,
  781. children: [
  782. Icon(Icons.access_time_outlined,
  783. size: 13, color: cs.onSurface.withAlpha(100)),
  784. const SizedBox(width: 4),
  785. Flexible(
  786. child: Text(
  787. label,
  788. style: TextStyle(
  789. color: cs.onSurface.withAlpha(153), fontSize: 12),
  790. overflow: TextOverflow.ellipsis,
  791. ),
  792. ),
  793. ],
  794. ),
  795. ),
  796. );
  797. }
  798. }
  799. // ══════════════════════════════════════════════════════════════
  800. // 滚筒日期选择器
  801. // ══════════════════════════════════════════════════════════════
  802. Future<DateTime?> _showDrumDatePicker({
  803. required BuildContext context,
  804. required DateTime initialDate,
  805. required DateTime firstDate,
  806. required DateTime lastDate,
  807. String? title,
  808. }) {
  809. final isDark = Theme.of(context).brightness == Brightness.dark;
  810. final resolvedTitle = title ?? AppLocalizations.of(context)!.selectDate;
  811. return showModalBottomSheet<DateTime>(
  812. context: context,
  813. useRootNavigator: true,
  814. backgroundColor: Colors.transparent,
  815. isScrollControlled: true,
  816. builder: (ctx) => _DrumDatePickerSheet(
  817. initialDate: initialDate,
  818. firstDate: firstDate,
  819. lastDate: lastDate,
  820. title: resolvedTitle,
  821. isDark: isDark,
  822. ),
  823. );
  824. }
  825. class _DrumDatePickerSheet extends StatefulWidget {
  826. const _DrumDatePickerSheet({
  827. required this.initialDate,
  828. required this.firstDate,
  829. required this.lastDate,
  830. required this.title,
  831. required this.isDark,
  832. });
  833. final DateTime initialDate;
  834. final DateTime firstDate;
  835. final DateTime lastDate;
  836. final String title;
  837. final bool isDark;
  838. @override
  839. State<_DrumDatePickerSheet> createState() => _DrumDatePickerSheetState();
  840. }
  841. class _DrumDatePickerSheetState extends State<_DrumDatePickerSheet> {
  842. late int _year;
  843. late int _month;
  844. late int _day;
  845. late List<int> _years;
  846. late FixedExtentScrollController _yearCtrl;
  847. late FixedExtentScrollController _monthCtrl;
  848. late FixedExtentScrollController _dayCtrl;
  849. @override
  850. void initState() {
  851. super.initState();
  852. _years = List.generate(
  853. widget.lastDate.year - widget.firstDate.year + 1,
  854. (i) => widget.firstDate.year + i,
  855. );
  856. _year = widget.initialDate.year.clamp(widget.firstDate.year, widget.lastDate.year);
  857. _month = widget.initialDate.month;
  858. _day = widget.initialDate.day.clamp(1, _daysInMonth);
  859. _yearCtrl = FixedExtentScrollController(initialItem: _year - widget.firstDate.year);
  860. _monthCtrl = FixedExtentScrollController(initialItem: _month - 1);
  861. _dayCtrl = FixedExtentScrollController(initialItem: _day - 1);
  862. }
  863. @override
  864. void dispose() {
  865. _yearCtrl.dispose();
  866. _monthCtrl.dispose();
  867. _dayCtrl.dispose();
  868. super.dispose();
  869. }
  870. int get _daysInMonth => DateTime(_year, _month + 1, 0).day;
  871. void _onYearChanged(int index) {
  872. setState(() {
  873. _year = _years[index];
  874. _clampDay();
  875. });
  876. }
  877. void _onMonthChanged(int index) {
  878. setState(() {
  879. _month = index + 1;
  880. _clampDay();
  881. });
  882. }
  883. void _onDayChanged(int index) {
  884. setState(() {
  885. _day = (index + 1).clamp(1, _daysInMonth);
  886. });
  887. }
  888. void _clampDay() {
  889. final maxDay = _daysInMonth;
  890. if (_day > maxDay) {
  891. _day = maxDay;
  892. WidgetsBinding.instance.addPostFrameCallback((_) {
  893. if (_dayCtrl.hasClients) _dayCtrl.jumpToItem(_day - 1);
  894. });
  895. }
  896. }
  897. @override
  898. Widget build(BuildContext context) {
  899. final cs = Theme.of(context).colorScheme;
  900. final sheetBg = widget.isDark ? const Color(0xFF1C2128) : Colors.white;
  901. final highlightBg = widget.isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary;
  902. final gradientColor = widget.isDark ? const Color(0xFF1C2128) : Colors.white;
  903. final dividerColor = widget.isDark ? Colors.white.withAlpha(20) : const Color(0xFFF0F0F0);
  904. final textStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface);
  905. final preview =
  906. '$_year-${_month.toString().padLeft(2, '0')}-${_day.toString().padLeft(2, '0')}';
  907. return Container(
  908. decoration: BoxDecoration(
  909. color: sheetBg,
  910. borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
  911. ),
  912. child: SafeArea(
  913. child: LayoutBuilder(
  914. builder: (context, constraints) {
  915. final mq = MediaQuery.of(context);
  916. // SafeArea 已处理底部;此处为工具栏 + 预览 + 分隔线 + 底留白
  917. const fixedToolbar = 50.0 + 44.0 + 2.0 + 8.0;
  918. final maxH = constraints.maxHeight.isFinite
  919. ? constraints.maxHeight
  920. : mq.size.height * 0.42;
  921. final wheelH =
  922. (maxH - fixedToolbar).clamp(160.0, 240.0);
  923. return Column(
  924. mainAxisSize: MainAxisSize.min,
  925. children: [
  926. SizedBox(
  927. height: 50,
  928. child: Row(
  929. children: [
  930. TextButton(
  931. onPressed: () => Navigator.pop(context),
  932. child: Text(AppLocalizations.of(context)!.cancel,
  933. style: TextStyle(
  934. fontSize: 15,
  935. fontWeight: FontWeight.w500,
  936. color: cs.onSurface.withAlpha(153))),
  937. ),
  938. Expanded(
  939. child: Text(widget.title,
  940. textAlign: TextAlign.center,
  941. style: TextStyle(
  942. fontSize: 16,
  943. fontWeight: FontWeight.w600,
  944. color: cs.onSurface)),
  945. ),
  946. TextButton(
  947. onPressed: () => Navigator.pop(context, DateTime(_year, _month, _day)),
  948. child: Text(AppLocalizations.of(context)!.confirm,
  949. style: const TextStyle(
  950. fontSize: 15,
  951. fontWeight: FontWeight.w600,
  952. color: AppColors.brand)),
  953. ),
  954. ],
  955. ),
  956. ),
  957. Divider(height: 1, color: dividerColor),
  958. // ── 预览行 ──────────────────────────────────────
  959. SizedBox(
  960. height: 44,
  961. child: Center(
  962. child: Text(preview,
  963. style: const TextStyle(
  964. fontSize: 15,
  965. fontWeight: FontWeight.w600,
  966. color: AppColors.brand)),
  967. ),
  968. ),
  969. Divider(height: 1, color: dividerColor),
  970. SizedBox(
  971. height: wheelH,
  972. child: Stack(
  973. children: [
  974. // 选中行高亮背景
  975. Positioned.fill(
  976. child: Center(
  977. child: Container(
  978. height: 44,
  979. margin: const EdgeInsets.symmetric(horizontal: 8),
  980. decoration: BoxDecoration(
  981. color: highlightBg,
  982. borderRadius: BorderRadius.circular(8),
  983. ),
  984. ),
  985. ),
  986. ),
  987. // 三列
  988. Row(
  989. children: [
  990. // 年份列
  991. Expanded(
  992. flex: 5,
  993. child: ListWheelScrollView.useDelegate(
  994. controller: _yearCtrl,
  995. itemExtent: 44,
  996. physics: const FixedExtentScrollPhysics(),
  997. onSelectedItemChanged: _onYearChanged,
  998. childDelegate: ListWheelChildBuilderDelegate(
  999. childCount: _years.length,
  1000. builder: (_, i) => Center(
  1001. child: Text('${_years[i]}${AppLocalizations.of(context)!.yearSuffix}', style: textStyle),
  1002. ),
  1003. ),
  1004. ),
  1005. ),
  1006. // 月份列
  1007. Expanded(
  1008. flex: 3,
  1009. child: ListWheelScrollView.useDelegate(
  1010. controller: _monthCtrl,
  1011. itemExtent: 44,
  1012. physics: const FixedExtentScrollPhysics(),
  1013. onSelectedItemChanged: _onMonthChanged,
  1014. childDelegate: ListWheelChildBuilderDelegate(
  1015. childCount: 12,
  1016. builder: (_, i) => Center(
  1017. child: Text('${i + 1}${AppLocalizations.of(context)!.monthSuffix}', style: textStyle),
  1018. ),
  1019. ),
  1020. ),
  1021. ),
  1022. // 日期列
  1023. Expanded(
  1024. flex: 3,
  1025. child: ListWheelScrollView.useDelegate(
  1026. controller: _dayCtrl,
  1027. itemExtent: 44,
  1028. physics: const FixedExtentScrollPhysics(),
  1029. onSelectedItemChanged: _onDayChanged,
  1030. childDelegate: ListWheelChildBuilderDelegate(
  1031. childCount: _daysInMonth,
  1032. builder: (_, i) => Center(
  1033. child: Text('${i + 1}${AppLocalizations.of(context)!.daySuffix}', style: textStyle),
  1034. ),
  1035. ),
  1036. ),
  1037. ),
  1038. ],
  1039. ),
  1040. // 上渐变遮罩
  1041. Positioned(
  1042. top: 0, left: 0, right: 0, height: 88,
  1043. child: IgnorePointer(
  1044. child: Container(
  1045. decoration: BoxDecoration(
  1046. gradient: LinearGradient(
  1047. begin: Alignment.topCenter,
  1048. end: Alignment.bottomCenter,
  1049. colors: [gradientColor, gradientColor.withAlpha(0)],
  1050. ),
  1051. ),
  1052. ),
  1053. ),
  1054. ),
  1055. // 下渐变遮罩
  1056. Positioned(
  1057. bottom: 0, left: 0, right: 0, height: 88,
  1058. child: IgnorePointer(
  1059. child: Container(
  1060. decoration: BoxDecoration(
  1061. gradient: LinearGradient(
  1062. begin: Alignment.bottomCenter,
  1063. end: Alignment.topCenter,
  1064. colors: [gradientColor, gradientColor.withAlpha(0)],
  1065. ),
  1066. ),
  1067. ),
  1068. ),
  1069. ),
  1070. ],
  1071. ),
  1072. ),
  1073. const SizedBox(height: 8),
  1074. ],
  1075. );
  1076. },
  1077. ),
  1078. ),
  1079. );
  1080. }
  1081. }