| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143 |
- import 'dart:math' as math;
- import 'package:flutter/material.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import 'package:intl/intl.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../../data/models/asset/asset_statement.dart';
- import '../../../providers/statement_provider.dart';
- import '../../widgets/common/app_refresh_indicator.dart';
- class AssetHistoryScreen extends ConsumerWidget {
- const AssetHistoryScreen({super.key});
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final state = ref.watch(statementProvider);
- final notifier = ref.read(statementProvider.notifier);
- final filterCache = ref.watch(statementFilterCacheProvider);
- ref.listen<StatementState>(statementProvider, (prev, next) {
- if (next.errorMessage != null && next.errorMessage != prev?.errorMessage) {
- final l10n = AppLocalizations.of(context)!;
- showTopToast(context,
- message: resolveProviderError(next.errorMessage!, l10n) ?? next.errorMessage!,
- backgroundColor: AppColors.fall);
- }
- });
- return Scaffold(
- appBar: AppBar(
- leading: IconButton(
- icon: const Icon(Icons.chevron_left, size: 28),
- onPressed: () => context.pop(),
- ),
- title: Text(AppLocalizations.of(context)!.fundRecord,
- style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
- centerTitle: true,
- ),
- body: Column(
- children: [
- _FilterSection(
- state: state,
- notifier: notifier,
- coins: filterCache.coins,
- types: filterCache.types,
- ),
- Expanded(
- child: _buildList(context, state, notifier, filterCache.types),
- ),
- ],
- ),
- );
- }
- Widget _buildList(
- BuildContext context,
- StatementState state,
- StatementNotifier notifier,
- List<StatementType> types,
- ) {
- final cs = Theme.of(context).colorScheme;
- if (state.isLoading && state.records.isEmpty) {
- return const Center(child: CircularProgressIndicator());
- }
- if (state.records.isEmpty) {
- return Center(
- child: Text(AppLocalizations.of(context)!.noRecord,
- style:
- TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 15)),
- );
- }
- return AppRefreshIndicator(
- onRefresh: notifier.refresh,
- child: NotificationListener<ScrollNotification>(
- onNotification: (notification) {
- if (notification is ScrollEndNotification &&
- notification.metrics.pixels >=
- notification.metrics.maxScrollExtent - 100) {
- notifier.loadMore();
- }
- return false;
- },
- child: ListView.separated(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
- itemCount: state.records.length + (state.hasMore ? 1 : 0),
- separatorBuilder: (_, __) => const SizedBox(height: 10),
- itemBuilder: (_, i) {
- if (i >= state.records.length) {
- return const Padding(
- padding: EdgeInsets.symmetric(vertical: 16),
- child:
- Center(child: CircularProgressIndicator(strokeWidth: 2)),
- );
- }
- return _RecordCard(
- record: state.records[i],
- types: types,
- coinCode: state.selectedCoinCode,
- );
- },
- ),
- ),
- );
- }
- }
- Map<String, String> _buildLocalTypeNames(AppLocalizations l10n) => {
- '0': l10n.txType0, '1': l10n.txType1, '2': l10n.txType2, '3': l10n.txType3, '4': l10n.txType4,
- '5': l10n.txType5, '6': l10n.txType6, '7': l10n.txType7, '8': l10n.txType8, '9': l10n.txType9,
- '10': l10n.txType10, '11': l10n.txType11, '13': l10n.txType13, '14': l10n.txType14,
- '15': l10n.txType15, '16': l10n.txType16, '17': l10n.txType17, '18': l10n.txType18,
- '19': l10n.txType19, '20': l10n.txType20, '21': l10n.txType21, '22': l10n.txType22,
- '23': l10n.txType23, '24': l10n.txType24, '25': l10n.txType25, '26': l10n.txType26,
- '27': l10n.txType27, '28': l10n.txType28, '29': l10n.txType29,
- '30': l10n.txType30, '31': l10n.txType31, '32': l10n.txType32, '33': l10n.txType33,
- '34': l10n.txType34, '35': l10n.txType35, '36': l10n.txType36,
- '37': l10n.txType37, '38': l10n.txType38, '39': l10n.txType39,
- '40': l10n.txType40, '41': l10n.txType41, '42': l10n.txType42,
- '43': l10n.txType43, '44': l10n.txType44, '45': l10n.txType45, '46': l10n.txType46,
- '47': l10n.txType47, '48': l10n.txType48, '49': l10n.txType49, '50': l10n.txType50, '51': l10n.txType51,
- };
- // ══════════════════════════════════════════════════════════════
- // 筛选区
- // ══════════════════════════════════════════════════════════════
- class _FilterSection extends StatelessWidget {
- const _FilterSection({
- required this.state,
- required this.notifier,
- required this.coins,
- required this.types,
- });
- final StatementState state;
- final StatementNotifier notifier;
- final List<StatementCoin> coins;
- final List<StatementType> types;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final dateFmt = DateFormat('yyyy-MM-dd');
- final l10n = AppLocalizations.of(context)!;
- // 构建选项列表:(value, label)
- final coinOptions = <(String, String)>[
- ('', l10n.all),
- ...coins.map((c) => (c.code, c.code)),
- ];
- final localTypeNames = _buildLocalTypeNames(l10n);
- final typeOptions = <(String, String)>[
- ('', l10n.all),
- ...types.map((t) => (t.typeId, localTypeNames[t.typeId] ?? t.name)),
- ];
- final coinLabel = coinOptions
- .firstWhere((e) => e.$1 == state.selectedCoinCode,
- orElse: () => ('', l10n.all))
- .$2;
- final typeLabel = typeOptions
- .firstWhere((e) => e.$1 == state.selectedTypeId,
- orElse: () => ('', l10n.all))
- .$2;
- return Padding(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 币种 + 类型
- Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Text(l10n.coinLabel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 12)),
- const SizedBox(height: 6),
- _PickerField(
- label: coinLabel,
- items: coinOptions,
- value: state.selectedCoinCode,
- title: l10n.selectCoin,
- onChanged: (v) => notifier.selectCoin(v),
- ),
- ],
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Text(l10n.typeLabel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 12)),
- const SizedBox(height: 6),
- _PickerField(
- label: typeLabel,
- items: typeOptions,
- value: state.selectedTypeId,
- title: l10n.selectType,
- onChanged: (v) => notifier.selectType(v),
- ),
- ],
- ),
- ),
- ],
- ),
- const SizedBox(height: 12),
- // 起止时间 + 重置/搜索
- Text(l10n.timeRange,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 12)),
- const SizedBox(height: 6),
- Row(
- children: [
- Expanded(
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary,
- border: Border.all(
- color: isDark
- ? AppColors.darkCardBorder.withAlpha(180)
- : AppColors.lightBorder),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Row(
- children: [
- Flexible(
- child: _TimeChip(
- label: state.startDate != null
- ? dateFmt.format(state.startDate!)
- : l10n.startTime,
- onTap: () async {
- final date = await _showDrumDatePicker(
- context: context,
- initialDate: state.startDate ?? DateTime.now(),
- firstDate: DateTime(2017),
- lastDate: DateTime.now(),
- title: l10n.startTime,
- );
- if (date != null) notifier.selectStartDate(date);
- },
- ),
- ),
- Text('~',
- style: TextStyle(
- color: cs.onSurface.withAlpha(120))),
- Flexible(
- child: _TimeChip(
- label: state.endDate != null
- ? dateFmt.format(state.endDate!)
- : l10n.endTime,
- onTap: () async {
- final date = await _showDrumDatePicker(
- context: context,
- initialDate: state.endDate ?? DateTime.now(),
- firstDate: DateTime(2017),
- lastDate: DateTime.now(),
- title: l10n.endTime,
- );
- if (date != null) notifier.selectEndDate(date);
- },
- ),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: notifier.reset,
- child: Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
- decoration: BoxDecoration(
- border: Border.all(
- color: isDark
- ? AppColors.darkCardBorder.withAlpha(180)
- : AppColors.lightBorder),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Text(l10n.reset,
- style:
- TextStyle(color: cs.onSurface, fontSize: 13)),
- ),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: notifier.search,
- child: Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 20, vertical: 9),
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Text(l10n.search,
- style: const TextStyle(
- color: Colors.black,
- fontSize: 13,
- fontWeight: FontWeight.w600)),
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
- // ══════════════════════════════════════════════════════════════
- // 自定义选择器字段(替代 DropdownButtonFormField)
- // ══════════════════════════════════════════════════════════════
- class _PickerField extends StatelessWidget {
- const _PickerField({
- required this.label,
- required this.items,
- required this.value,
- required this.title,
- required this.onChanged,
- });
- final String label;
- final List<(String, String)> items; // (value, displayLabel)
- final String value;
- final String title;
- final ValueChanged<String> onChanged;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final fieldBg = isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary;
- final borderColor = isDark ? AppColors.darkCardBorder.withAlpha(180) : AppColors.lightBorder;
- return LayoutBuilder(
- builder: (_, constraints) => GestureDetector(
- onTap: () => _showSheet(context),
- child: Container(
- width: constraints.maxWidth,
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
- decoration: BoxDecoration(
- color: fieldBg,
- border: Border.all(color: borderColor),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Row(
- children: [
- Expanded(
- child: Text(
- label,
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- overflow: TextOverflow.ellipsis,
- ),
- ),
- Icon(Icons.keyboard_arrow_down,
- size: 18, color: cs.onSurface.withAlpha(120)),
- ],
- ),
- ),
- ),
- );
- }
- void _showSheet(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- backgroundColor:
- isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (ctx) {
- final mq = MediaQuery.of(ctx);
- return LayoutBuilder(
- builder: (ctx, constraints) {
- // 把手 + 标题 + 分隔线 + 底安全区,与下方列表分离避免超出 BottomSheet 高度
- const topReserve = 100.0;
- final bottomGap = mq.padding.bottom + 8;
- final parentH = constraints.maxHeight;
- final listMax = parentH.isFinite
- ? math.min(
- mq.size.height * 0.45,
- math.max(120.0, parentH - topReserve - bottomGap),
- )
- : mq.size.height * 0.45;
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Padding(
- padding: const EdgeInsets.only(top: 12, bottom: 4),
- child: Container(
- width: 36,
- height: 4,
- decoration: BoxDecoration(
- color: cs.onSurface.withAlpha(40),
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
- child: Text(title,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 15,
- fontWeight: FontWeight.w600)),
- ),
- Divider(height: 1, color: cs.outline.withAlpha(40)),
- ConstrainedBox(
- constraints: BoxConstraints(maxHeight: listMax),
- child: ListView.separated(
- shrinkWrap: true,
- itemCount: items.length,
- separatorBuilder: (_, __) =>
- Divider(height: 1, color: cs.outline.withAlpha(25)),
- itemBuilder: (_, i) {
- final (val, lbl) = items[i];
- final isSelected = val == value;
- return GestureDetector(
- onTap: () {
- Navigator.pop(ctx);
- onChanged(val);
- },
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 20, vertical: 14),
- child: Row(
- children: [
- Expanded(
- child: Text(
- lbl,
- style: TextStyle(
- color: isSelected
- ? AppColors.brand
- : cs.onSurface,
- fontSize: 15,
- fontWeight: isSelected
- ? FontWeight.w600
- : FontWeight.w400,
- ),
- ),
- ),
- if (isSelected)
- const Icon(Icons.check,
- size: 18, color: AppColors.brand),
- ],
- ),
- ),
- );
- },
- ),
- ),
- SizedBox(height: bottomGap),
- ],
- );
- },
- );
- },
- );
- }
- }
- // ══════════════════════════════════════════════════════════════
- // 流水记录卡片
- // ══════════════════════════════════════════════════════════════
- /// 类别行展示:去掉服务端固定的中文「永续」或英文 perpetual,改为 [AppLocalizations.perpetual]。
- String localizedStatementCategorySymbol(String raw, AppLocalizations l10n) {
- final s = raw.trim();
- if (s.isEmpty) {
- return s;
- }
- final zh = RegExp(r'\s*永续\s*$');
- if (zh.hasMatch(s)) {
- final base = s.replaceFirst(zh, '').trim();
- if (base.isEmpty) {
- return l10n.perpetual;
- }
- return '$base ${l10n.perpetual}';
- }
- final en = RegExp(r'\s+perpetual\s*$', caseSensitive: false);
- if (en.hasMatch(s)) {
- final base = s.replaceFirst(en, '').trim();
- if (base.isEmpty) {
- return l10n.perpetual;
- }
- return '$base ${l10n.perpetual}';
- }
- return s;
- }
- /// 现货成交(type=48)类别不与永续挂钩:去掉尾部永续表述,仅保留标的(与 Web 一致)。
- String localizedStatementCategoryForRecord(
- AssetStatement record,
- AppLocalizations l10n,
- ) {
- if (record.type == '48') {
- return _stripPerpetualSuffixRaw(record.displaySymbol);
- }
- return localizedStatementCategorySymbol(record.displaySymbol, l10n);
- }
- String _stripPerpetualSuffixRaw(String raw) {
- final s = raw.trim();
- if (s.isEmpty) {
- return s;
- }
- var t = s.replaceFirst(RegExp(r'\s*永续\s*$'), '').trim();
- t = t.replaceFirst(RegExp(r'\s+perpetual\s*$', caseSensitive: false), '').trim();
- t = t.replaceFirst(RegExp(r'\s*無期限\s*$'), '').trim();
- if (t.isEmpty) {
- return s.split(RegExp(r'\s+')).first;
- }
- return t;
- }
- class _RecordCard extends StatelessWidget {
- const _RecordCard({
- required this.record,
- required this.types,
- this.coinCode = '',
- });
- final AssetStatement record;
- final List<StatementType> types;
- final String coinCode;
- String _localizedExtraTitle(AppLocalizations l10n) {
- switch (record.type) {
- case '1': return l10n.feeUsdt;
- case '19': return l10n.openCloseLabel;
- case '25': return l10n.rebateId;
- default: return '';
- }
- }
- String _localizedExtraValue(AppLocalizations l10n) {
- switch (record.type) {
- case '1': return record.fee;
- case '19': return record.direction == '0' ? l10n.openPosition : l10n.closePosition;
- case '25': return record.rebateId;
- default: return '';
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final amount = double.tryParse(record.amount) ?? 0;
- final isPositive = amount >= 0;
- final amountColor = isPositive ? AppColors.rise : AppColors.fall;
- final amountStr =
- isPositive ? '+${_formatAmount(amount)}' : _formatAmount(amount);
- final l10n = AppLocalizations.of(context)!;
- final typeName = _findTypeName(record.type, l10n);
- final coin = coinCode.isNotEmpty ? coinCode : 'USDT';
- // 数量标签用记录本身的币种(取 symbol 第一个词,如 "BTC 永续" → "BTC")
- final amountCoin = record.symbol.isNotEmpty
- ? record.symbol.split(' ').first
- : coin;
- final timeParts = record.createTime.split(' ');
- final datePart = timeParts.isNotEmpty ? timeParts[0] : record.createTime;
- final timePart = timeParts.length > 1 ? timeParts[1] : '';
- final hasExtra = record.showExtraField || record.symbol.isNotEmpty;
- // 颜色分层:header背景 vs body背景
- final headerBg =
- isDark ? AppColors.brand.withAlpha(90) : AppColors.tagIndigoBgLight;
- final bodyBg = isDark ? AppColors.darkBgSecondary : AppColors.lightBg;
- final borderColor =
- isDark ? AppColors.darkCardBorder : AppColors.lightBorder;
- return Container(
- clipBehavior: Clip.antiAlias,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: borderColor, width: 0.5),
- color: bodyBg, // 封住底部 + 为 Divider 提供背景色
- ),
- child: Column(
- children: [
- // ── Header:币种 + 已完成(带背景色)──────────
- Container(
- color: headerBg,
- padding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
- child: Row(
- children: [
- Text(
- 'USDT',
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w600,
- ),
- ),
- const Spacer(),
- Text(
- AppLocalizations.of(context)!.completed,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 12,
- ),
- ),
- ],
- ),
- ),
- // ── Body ────────────────────────────────────
- Padding(
- padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 类型
- Expanded(
- flex: 4,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(AppLocalizations.of(context)!.typeLabel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 11)),
- const SizedBox(height: 5),
- Text(
- typeName,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w500,
- ),
- ),
- ],
- ),
- ),
- // 时间
- Expanded(
- flex: 4,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(AppLocalizations.of(context)!.timeLabel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 11)),
- const SizedBox(height: 5),
- Text(datePart,
- style: TextStyle(
- color: cs.onSurface, fontSize: 12)),
- if (timePart.isNotEmpty)
- Text(timePart,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 12)),
- ],
- ),
- ),
- // 数量(右对齐)
- Expanded(
- flex: 4,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text('${AppLocalizations.of(context)!.amountLabel}($amountCoin)',
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 11)),
- const SizedBox(height: 5),
- Text(
- amountStr,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: amountColor,
- fontSize: 14,
- fontWeight: FontWeight.w600,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- // ── 额外字段行(类别 / 开平仓)──────────────
- if (hasExtra) ...[
- Divider(
- height: 1,
- thickness: 0.5,
- color: borderColor,
- indent: 14,
- endIndent: 14,
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
- child: Row(
- children: [
- if (record.symbol.isNotEmpty)
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(AppLocalizations.of(context)!.categoryLabel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 11)),
- const SizedBox(height: 5),
- Text(localizedStatementCategoryForRecord(record, l10n),
- style: TextStyle(
- color: cs.onSurface, fontSize: 13)),
- ],
- ),
- ),
- if (record.showExtraField)
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(_localizedExtraTitle(l10n),
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 11)),
- const SizedBox(height: 5),
- Text(_localizedExtraValue(l10n),
- style: TextStyle(
- color: cs.onSurface, fontSize: 13)),
- ],
- ),
- ),
- ],
- ),
- ),
- ],
- ],
- ),
- );
- }
- Map<String, String> _localTypeNames(AppLocalizations l10n) => _buildLocalTypeNames(l10n);
- String _findTypeName(String typeId, AppLocalizations l10n) {
- final localName = _localTypeNames(l10n)[typeId];
- if (localName != null) return localName;
- for (final t in types) {
- if (t.typeId == typeId) return t.name;
- }
- return typeId;
- }
- String _formatAmount(double value) {
- final abs = value.abs();
- int decimals;
- if (abs < 0.0001) {
- decimals = 8;
- } else if (abs < 0.001) {
- decimals = 7;
- } else if (abs < 0.01) {
- decimals = 6;
- } else if (abs < 0.1) {
- decimals = 5;
- } else if (abs < 1) {
- decimals = 4;
- } else {
- decimals = 2;
- }
- final factor = _pow10(decimals);
- final truncated = (value * factor).truncateToDouble() / factor;
- var str = truncated.toStringAsFixed(decimals);
- if (str.contains('.')) {
- str = str.replaceAll(RegExp(r'0+$'), '');
- str = str.replaceAll(RegExp(r'\.$'), '');
- }
- return str;
- }
- double _pow10(int n) {
- double result = 1;
- for (int i = 0; i < n; i++) result *= 10;
- return result;
- }
- }
- // ══════════════════════════════════════════════════════════════
- // 时间选择 Chip
- // ══════════════════════════════════════════════════════════════
- class _TimeChip extends StatelessWidget {
- const _TimeChip({required this.label, required this.onTap});
- final String label;
- final VoidCallback onTap;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return GestureDetector(
- onTap: onTap,
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 5),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.access_time_outlined,
- size: 13, color: cs.onSurface.withAlpha(100)),
- const SizedBox(width: 4),
- Flexible(
- child: Text(
- label,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 12),
- overflow: TextOverflow.ellipsis,
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
- // ══════════════════════════════════════════════════════════════
- // 滚筒日期选择器
- // ══════════════════════════════════════════════════════════════
- Future<DateTime?> _showDrumDatePicker({
- required BuildContext context,
- required DateTime initialDate,
- required DateTime firstDate,
- required DateTime lastDate,
- String? title,
- }) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final resolvedTitle = title ?? AppLocalizations.of(context)!.selectDate;
- return showModalBottomSheet<DateTime>(
- context: context,
- useRootNavigator: true,
- backgroundColor: Colors.transparent,
- isScrollControlled: true,
- builder: (ctx) => _DrumDatePickerSheet(
- initialDate: initialDate,
- firstDate: firstDate,
- lastDate: lastDate,
- title: resolvedTitle,
- isDark: isDark,
- ),
- );
- }
- class _DrumDatePickerSheet extends StatefulWidget {
- const _DrumDatePickerSheet({
- required this.initialDate,
- required this.firstDate,
- required this.lastDate,
- required this.title,
- required this.isDark,
- });
- final DateTime initialDate;
- final DateTime firstDate;
- final DateTime lastDate;
- final String title;
- final bool isDark;
- @override
- State<_DrumDatePickerSheet> createState() => _DrumDatePickerSheetState();
- }
- class _DrumDatePickerSheetState extends State<_DrumDatePickerSheet> {
- late int _year;
- late int _month;
- late int _day;
- late List<int> _years;
- late FixedExtentScrollController _yearCtrl;
- late FixedExtentScrollController _monthCtrl;
- late FixedExtentScrollController _dayCtrl;
- @override
- void initState() {
- super.initState();
- _years = List.generate(
- widget.lastDate.year - widget.firstDate.year + 1,
- (i) => widget.firstDate.year + i,
- );
- _year = widget.initialDate.year.clamp(widget.firstDate.year, widget.lastDate.year);
- _month = widget.initialDate.month;
- _day = widget.initialDate.day.clamp(1, _daysInMonth);
- _yearCtrl = FixedExtentScrollController(initialItem: _year - widget.firstDate.year);
- _monthCtrl = FixedExtentScrollController(initialItem: _month - 1);
- _dayCtrl = FixedExtentScrollController(initialItem: _day - 1);
- }
- @override
- void dispose() {
- _yearCtrl.dispose();
- _monthCtrl.dispose();
- _dayCtrl.dispose();
- super.dispose();
- }
- int get _daysInMonth => DateTime(_year, _month + 1, 0).day;
- void _onYearChanged(int index) {
- setState(() {
- _year = _years[index];
- _clampDay();
- });
- }
- void _onMonthChanged(int index) {
- setState(() {
- _month = index + 1;
- _clampDay();
- });
- }
- void _onDayChanged(int index) {
- setState(() {
- _day = (index + 1).clamp(1, _daysInMonth);
- });
- }
- void _clampDay() {
- final maxDay = _daysInMonth;
- if (_day > maxDay) {
- _day = maxDay;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (_dayCtrl.hasClients) _dayCtrl.jumpToItem(_day - 1);
- });
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final sheetBg = widget.isDark ? const Color(0xFF1C2128) : Colors.white;
- final highlightBg = widget.isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary;
- final gradientColor = widget.isDark ? const Color(0xFF1C2128) : Colors.white;
- final dividerColor = widget.isDark ? Colors.white.withAlpha(20) : const Color(0xFFF0F0F0);
- final textStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface);
- final preview =
- '$_year-${_month.toString().padLeft(2, '0')}-${_day.toString().padLeft(2, '0')}';
- return Container(
- decoration: BoxDecoration(
- color: sheetBg,
- borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
- ),
- child: SafeArea(
- child: LayoutBuilder(
- builder: (context, constraints) {
- final mq = MediaQuery.of(context);
- // SafeArea 已处理底部;此处为工具栏 + 预览 + 分隔线 + 底留白
- const fixedToolbar = 50.0 + 44.0 + 2.0 + 8.0;
- final maxH = constraints.maxHeight.isFinite
- ? constraints.maxHeight
- : mq.size.height * 0.42;
- final wheelH =
- (maxH - fixedToolbar).clamp(160.0, 240.0);
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- SizedBox(
- height: 50,
- child: Row(
- children: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: Text(AppLocalizations.of(context)!.cancel,
- style: TextStyle(
- fontSize: 15,
- fontWeight: FontWeight.w500,
- color: cs.onSurface.withAlpha(153))),
- ),
- Expanded(
- child: Text(widget.title,
- textAlign: TextAlign.center,
- style: TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w600,
- color: cs.onSurface)),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context, DateTime(_year, _month, _day)),
- child: Text(AppLocalizations.of(context)!.confirm,
- style: const TextStyle(
- fontSize: 15,
- fontWeight: FontWeight.w600,
- color: AppColors.brand)),
- ),
- ],
- ),
- ),
- Divider(height: 1, color: dividerColor),
- // ── 预览行 ──────────────────────────────────────
- SizedBox(
- height: 44,
- child: Center(
- child: Text(preview,
- style: const TextStyle(
- fontSize: 15,
- fontWeight: FontWeight.w600,
- color: AppColors.brand)),
- ),
- ),
- Divider(height: 1, color: dividerColor),
- SizedBox(
- height: wheelH,
- child: Stack(
- children: [
- // 选中行高亮背景
- Positioned.fill(
- child: Center(
- child: Container(
- height: 44,
- margin: const EdgeInsets.symmetric(horizontal: 8),
- decoration: BoxDecoration(
- color: highlightBg,
- borderRadius: BorderRadius.circular(8),
- ),
- ),
- ),
- ),
- // 三列
- Row(
- children: [
- // 年份列
- Expanded(
- flex: 5,
- child: ListWheelScrollView.useDelegate(
- controller: _yearCtrl,
- itemExtent: 44,
- physics: const FixedExtentScrollPhysics(),
- onSelectedItemChanged: _onYearChanged,
- childDelegate: ListWheelChildBuilderDelegate(
- childCount: _years.length,
- builder: (_, i) => Center(
- child: Text('${_years[i]}${AppLocalizations.of(context)!.yearSuffix}', style: textStyle),
- ),
- ),
- ),
- ),
- // 月份列
- Expanded(
- flex: 3,
- child: ListWheelScrollView.useDelegate(
- controller: _monthCtrl,
- itemExtent: 44,
- physics: const FixedExtentScrollPhysics(),
- onSelectedItemChanged: _onMonthChanged,
- childDelegate: ListWheelChildBuilderDelegate(
- childCount: 12,
- builder: (_, i) => Center(
- child: Text('${i + 1}${AppLocalizations.of(context)!.monthSuffix}', style: textStyle),
- ),
- ),
- ),
- ),
- // 日期列
- Expanded(
- flex: 3,
- child: ListWheelScrollView.useDelegate(
- controller: _dayCtrl,
- itemExtent: 44,
- physics: const FixedExtentScrollPhysics(),
- onSelectedItemChanged: _onDayChanged,
- childDelegate: ListWheelChildBuilderDelegate(
- childCount: _daysInMonth,
- builder: (_, i) => Center(
- child: Text('${i + 1}${AppLocalizations.of(context)!.daySuffix}', style: textStyle),
- ),
- ),
- ),
- ),
- ],
- ),
- // 上渐变遮罩
- Positioned(
- top: 0, left: 0, right: 0, height: 88,
- child: IgnorePointer(
- child: Container(
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
- colors: [gradientColor, gradientColor.withAlpha(0)],
- ),
- ),
- ),
- ),
- ),
- // 下渐变遮罩
- Positioned(
- bottom: 0, left: 0, right: 0, height: 88,
- child: IgnorePointer(
- child: Container(
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.bottomCenter,
- end: Alignment.topCenter,
- colors: [gradientColor, gradientColor.withAlpha(0)],
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 8),
- ],
- );
- },
- ),
- ),
- );
- }
- }
|