import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../common/domain/metal_type.dart'; import '../../../common/domain/money.dart'; import '../../../common/router/app_router.dart'; import '../../../common/widgets/prototype_ui.dart'; import '../application/asset_portfolio_controller.dart'; import '../../profile/application/price_color_theme.dart'; import '../../profile/application/profile_settings_controller.dart'; import '../data/asset_models.dart'; import '../../market/application/market_controller.dart'; class AssetsPage extends ConsumerWidget { const AssetsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final summary = ref.watch(assetPortfolioControllerProvider); final settings = ref.watch(profileSettingsControllerProvider); final palette = priceColorPalette(settings.priceColorMode); final cs = ShadTheme.of(context).colorScheme; final hidden = settings.amountHidden; return SafeArea( top: false, child: ListView( padding: const EdgeInsets.fromLTRB(0, 8, 0, 86), children: [ PrototypeFrame( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ const Row( children: [ _Dot(), SizedBox(width: 8), Text( '金值', style: TextStyle( fontSize: 17, fontWeight: FontWeight.w600, ), ), ], ), const Spacer(), IconButton( onPressed: () => _showAddAssetSheet(context), icon: const Icon(Icons.add, size: 22), color: const Color(0xFF9A968D), padding: EdgeInsets.zero, constraints: const BoxConstraints.tightFor( width: 30, height: 30, ), ), IconButton( onPressed: () => ref .read(profileSettingsControllerProvider.notifier) .toggleAmountHidden(), icon: Icon( hidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 20, ), color: const Color(0xFF9A968D), padding: EdgeInsets.zero, constraints: const BoxConstraints.tightFor( width: 30, height: 30, ), ), ], ), const SizedBox(height: 26), Text( '我的贵金属总估值', textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: cs.mutedForeground), ), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.only(top: 11), child: Text( '¥', style: TextStyle( fontSize: 28, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 3), Text( hidden ? '••••••' : formatCny(summary.totalValue).replaceFirst('¥', ''), style: const TextStyle( fontSize: 64, fontWeight: FontWeight.w600, height: 1, letterSpacing: -2, ), ), ], ), const SizedBox(height: 15), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _ChangeChip( amount: summary.todayChangeAmount, percent: summary.todayChangePercent, upColor: Color(palette.up), downColor: Color(palette.down), upBackground: Color(palette.upBackground), downBackground: Color(palette.downBackground), ), const SizedBox(width: 10), Text( '今日', style: TextStyle(fontSize: 12, color: cs.mutedForeground), ), ], ), const SizedBox(height: 14), SizedBox( height: 32, child: ListView.separated( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), itemCount: summary.breakdowns.length, separatorBuilder: (context, index) => const SizedBox(width: 6), itemBuilder: (context, index) { final item = summary.breakdowns[index]; return PrototypePill( label: '${item.metal.shortLabel} ${hidden ? '•••' : formatCny(item.value)}', leading: Container( width: 6, height: 6, decoration: BoxDecoration( color: item.metal.color, shape: BoxShape.circle, ), ), ); }, ), ), const SizedBox(height: 14), Text( '更新于 16:11 · 行情聚合', textAlign: TextAlign.center, style: TextStyle( fontSize: 11, color: cs.mutedForeground.withValues(alpha: 0.82), ), ), const SizedBox(height: 12), GestureDetector( onTap: () => context.push( '${AppRoutes.marketKline}?metal=gold&period=24h', ), child: SizedBox( height: 46, child: ClipRRect( borderRadius: BorderRadius.circular(10), child: PrototypeLineChart( values: const [ 27, 31, 29, 35, 33, 37, 42, 40, 46, 44, 50, 53, ], ), ), ), ), const SizedBox(height: 14), GridView.count( crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 10, crossAxisSpacing: 10, childAspectRatio: 1.6, children: [ _StatCard( title: '回收参考', value: hidden ? '¥••••' : formatCny(summary.recycleReferenceValue), ), _StatCard( title: '总盈亏(已填成本)', value: hidden ? '••••' : _signedCny(summary.totalValue - summary.totalCost), valueColor: summary.totalValue - summary.totalCost >= 0 ? Color(palette.up) : Color(palette.down), ), _StatCard( title: '总成本', value: hidden ? '¥••••' : formatCny(summary.totalCost), ), _StatCard( title: '总克重', value: '${summary.totalWeightGram.toStringAsFixed(1)} g', ), ], ), const SizedBox(height: 12), PrototypeCard( padding: EdgeInsets.zero, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 15, 16, 8), child: Row( children: [ const Text( '我的持仓', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), const Spacer(), Text( '${summary.holdings.length} 件 · ${summary.totalWeightGram.toStringAsFixed(1)}g', style: TextStyle( fontSize: 12, color: cs.mutedForeground, ), ), ], ), ), const PrototypeDivider(margin: EdgeInsets.zero), for (final item in summary.holdings) ...[ _HoldingRow( valuation: item, hidden: hidden, upColor: Color(palette.up), downColor: Color(palette.down), onTap: () => context.push( '${AppRoutes.assetDetail}?id=${item.holding.id}', ), ), if (item != summary.holdings.last) const PrototypeDivider(margin: EdgeInsets.zero), ], const PrototypeDivider(margin: EdgeInsets.zero), Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), child: SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => _showAddAssetSheet(context), icon: const Icon(Icons.add, size: 18), label: const Text('添加资产'), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF9A7A2A), side: const BorderSide(color: Color(0xFFE3D6B4)), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ), ), ], ), ), ], ), ), ], ), ); } void _showAddAssetSheet(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), builder: (ctx) => const _AddAssetSheet(), ); } String _signedCny(double value) { final sign = value >= 0 ? '+' : '-'; return '$sign${formatCny(value.abs())}'; } } class _HoldingRow extends StatelessWidget { const _HoldingRow({ required this.valuation, required this.hidden, required this.upColor, required this.downColor, required this.onTap, }); final HoldingValuation valuation; final bool hidden; final Color upColor; final Color downColor; final VoidCallback onTap; @override Widget build(BuildContext context) { final holding = valuation.holding; final metal = holding.metal; final change = valuation.todayChange; final changeUp = change >= 0; return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), child: Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: metal.color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Icon( _iconFor(holding.category), size: 18, color: metal.color, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( holding.name, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( '${holding.purityLabel} · ${holding.weightGram.toStringAsFixed(1)}g' '${holding.note == null ? '' : ' · ${holding.note}'}', style: const TextStyle( fontSize: 12, color: Color(0xFF8A8780), ), ), ], ), ), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( hidden ? '¥••••' : formatCny(valuation.materialValue), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( '${changeUp ? '↑' : '↓'}${hidden ? '¥••••' : formatCny(change.abs())}', style: TextStyle( fontSize: 12, color: changeUp ? upColor : downColor, ), ), ], ), ], ), ), ); } IconData _iconFor(AssetCategory category) { return switch (category) { AssetCategory.bar || AssetCategory.bean => Icons.auto_awesome, AssetCategory.ring => Icons.radio_button_unchecked, AssetCategory.necklace => Icons.circle_outlined, AssetCategory.bracelet => Icons.watch_outlined, AssetCategory.earring => Icons.circle, AssetCategory.silverware => Icons.circle, AssetCategory.platinumPiece => Icons.circle, }; } } class _StatCard extends StatelessWidget { const _StatCard({required this.title, required this.value, this.valueColor}); final String title; final String value; final Color? valueColor; @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return PrototypeCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( title, style: TextStyle(fontSize: 12, color: cs.mutedForeground), ), Text( value, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: valueColor ?? cs.foreground, ), ), ], ), ); } } class _ChangeChip extends StatelessWidget { const _ChangeChip({ required this.amount, required this.percent, required this.upColor, required this.downColor, required this.upBackground, required this.downBackground, }); final double amount; final double percent; final Color upColor; final Color downColor; final Color upBackground; final Color downBackground; @override Widget build(BuildContext context) { final positive = amount >= 0; return Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), decoration: BoxDecoration( color: positive ? upBackground : downBackground, borderRadius: BorderRadius.circular(999), ), child: Text( '${positive ? '↑' : '↓'}${formatCny(amount.abs())} · ${positive ? '+' : '-'}${percent.abs().toStringAsFixed(2)}%', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: positive ? upColor : downColor, ), ), ); } } class _Dot extends StatelessWidget { const _Dot(); @override Widget build(BuildContext context) { return Container( width: 8, height: 8, decoration: const BoxDecoration( color: Color(0xFFB5862A), shape: BoxShape.circle, ), ); } } class _AddAssetSheet extends ConsumerStatefulWidget { const _AddAssetSheet(); @override ConsumerState<_AddAssetSheet> createState() => _AddAssetSheetState(); } class _AddAssetSheetState extends ConsumerState<_AddAssetSheet> { static const _cats = [ AssetCategory.bar, AssetCategory.necklace, AssetCategory.ring, AssetCategory.bracelet, AssetCategory.bean, AssetCategory.earring, AssetCategory.silverware, AssetCategory.platinumPiece, ]; static const _metalPurities = { MetalType.gold: ['足金9999', '足金999', '22K', '18K'], MetalType.platinum: ['PT990', 'PT950'], MetalType.silver: ['999银', '925银'], }; AssetCategory _cat = AssetCategory.bar; MetalType _metal = MetalType.gold; int _purityIndex = 0; double _gram = 50; bool _expanded = false; final _costCtrl = TextEditingController(); final _noteCtrl = TextEditingController(); final _dateCtrl = TextEditingController(text: '2025-08-12'); final _channelCtrl = TextEditingController(text: '银行'); @override void dispose() { _costCtrl.dispose(); _noteCtrl.dispose(); _dateCtrl.dispose(); _channelCtrl.dispose(); super.dispose(); } List get _purityOptions => _metalPurities[_metal]!; String get _purity => _purityOptions[_purityIndex.clamp(0, _purityOptions.length - 1)]; void _cyclePurity() { setState(() { _purityIndex = (_purityIndex + 1) % _purityOptions.length; }); } double _factorFor(String purity) { return switch (purity) { '足金9999' => 0.9999, '足金999' => 0.999, '22K' => 0.916, '18K' => 0.75, 'PT990' => 0.99, 'PT950' => 0.95, '999银' => 0.999, '925银' => 0.925, _ => 1, }; } String _nameForSelection() { return switch (_cat) { AssetCategory.bar => '投资金条', AssetCategory.necklace => '${_metal.label}项链', AssetCategory.ring => '${_metal.label}戒指', AssetCategory.bracelet => '${_metal.label}手镯', AssetCategory.bean => '金豆', AssetCategory.earring => '${_metal.label}耳环', AssetCategory.silverware => '银饰', AssetCategory.platinumPiece => 'PT饰品', }; } @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final market = ref.watch(marketControllerProvider); final quote = market.quotes.firstWhere((item) => item.metal == _metal); final basePrice = quote.spotPrice; final value = _gram * _factorFor(_purity) * basePrice; return SafeArea( child: Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 38, height: 4, margin: const EdgeInsets.only(bottom: 14), decoration: BoxDecoration( color: const Color(0xFFE3DFD4), borderRadius: BorderRadius.circular(999), ), ), ), Row( children: [ const Spacer(), const Text( '添加资产', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), const Spacer(), GestureDetector( onTap: () => Navigator.of(context).pop(), child: const Icon( Icons.close, size: 20, color: Color(0xFF8A8780), ), ), ], ), const SizedBox(height: 16), const Text( '选择品类', style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)), ), const SizedBox(height: 10), Wrap( spacing: 9, runSpacing: 9, children: [ for (final cat in _cats) _Chip( label: cat.label, selected: cat == _cat, onTap: () => setState(() { _cat = cat; if (cat == AssetCategory.silverware) { _metal = MetalType.silver; } else if (cat == AssetCategory.platinumPiece) { _metal = MetalType.platinum; } else { _metal = MetalType.gold; } _purityIndex = 0; }), ), ], ), const SizedBox(height: 16), Container( padding: const EdgeInsets.fromLTRB(16, 13, 16, 13), decoration: BoxDecoration( color: Colors.white, border: Border.all( color: const Color(0xFFE3D6B4), width: 0.5, ), borderRadius: BorderRadius.circular(13), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '金属 / 纯度', style: TextStyle( fontSize: 13, color: Color(0xFF8A8780), ), ), GestureDetector( onTap: _cyclePurity, child: Row( children: [ Text( '${_metal.label} · $_purity', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 7), const Icon( Icons.unfold_more, size: 16, color: Color(0xFFB3AFA5), ), ], ), ), ], ), ), const SizedBox(height: 16), const Text( '克重(克)', style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)), ), const SizedBox(height: 9), Row( children: [ _StepButton( icon: Icons.remove, onTap: () => setState(() => _gram = (_gram - 1).clamp(1, 200)), ), const SizedBox(width: 12), Expanded( child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), decoration: BoxDecoration( color: Colors.white, border: Border.all( color: const Color(0xFFE3D6B4), width: 0.5, ), borderRadius: BorderRadius.circular(13), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( _gram.toStringAsFixed(_gram % 1 == 0 ? 0 : 1), style: const TextStyle( fontSize: 32, fontWeight: FontWeight.w600, height: 1, ), ), const SizedBox(width: 6), const Padding( padding: EdgeInsets.only(bottom: 2), child: Text( '克', style: TextStyle( fontSize: 15, color: Color(0xFF8A8780), ), ), ), ], ), ), ), const SizedBox(width: 12), _StepButton( icon: Icons.add, onTap: () => setState(() => _gram = (_gram + 1).clamp(1, 200)), ), ], ), Slider( min: 1, max: 200, value: _gram.clamp(1, 200), onChanged: (value) => setState(() => _gram = value), activeColor: const Color(0xFFB5862A), inactiveColor: const Color(0xFFECEAE3), ), const SizedBox(height: 8), Container( padding: const EdgeInsets.fromLTRB(16, 15, 16, 16), decoration: BoxDecoration( color: const Color(0xFFF6ECD6), borderRadius: BorderRadius.circular(14), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '实时材料价值', style: TextStyle( fontSize: 13, color: Color(0xFF7A5B12), ), ), Text( formatCny(value), style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 7), Text( '按${_metal.label}现货 ${basePrice.toStringAsFixed(2)}/克 · 非金店零售价、非回收实收价', style: const TextStyle( fontSize: 11, color: Color(0xFF9A7A2A), ), ), ], ), ), const SizedBox(height: 14), GestureDetector( onTap: () => setState(() => _expanded = !_expanded), child: Container( padding: const EdgeInsets.fromLTRB(16, 13, 16, 13), decoration: BoxDecoration( color: cs.card, borderRadius: BorderRadius.circular(14), border: Border.all(color: cs.border, width: 0.5), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '选填:买入成本 · 日期 · 渠道 · 备注 · 照片', style: TextStyle( fontSize: 13, color: cs.mutedForeground, ), ), Icon( _expanded ? Icons.expand_less : Icons.expand_more, color: cs.mutedForeground, size: 18, ), ], ), ), ), if (_expanded) ...[ const SizedBox(height: 12), _FieldRow( label: '买入成本(含工费)', child: TextField( controller: _costCtrl, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( border: InputBorder.none, isDense: true, hintText: '选填', ), textAlign: TextAlign.right, ), ), const SizedBox(height: 10), _FieldRow( label: '购买日期', child: TextField( controller: _dateCtrl, decoration: const InputDecoration( border: InputBorder.none, isDense: true, ), textAlign: TextAlign.right, ), ), const SizedBox(height: 10), _FieldRow( label: '购买渠道', child: Wrap( spacing: 7, runSpacing: 7, alignment: WrapAlignment.end, children: [ for (final channel in ['银行', '金店', '电商', '回收市场', '其他']) _Chip( label: channel, selected: _channelCtrl.text == channel, onTap: () => setState(() => _channelCtrl.text = channel), ), ], ), ), const SizedBox(height: 10), _FieldRow( label: '备注', child: TextField( controller: _noteCtrl, decoration: const InputDecoration( border: InputBorder.none, isDense: true, hintText: '点击填写', ), textAlign: TextAlign.right, ), ), const SizedBox(height: 10), Container( padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFFECEAE3), width: 0.5, ), ), child: Row( children: [ Container( width: 60, height: 60, decoration: BoxDecoration( borderRadius: BorderRadius.circular(11), border: Border.all( color: const Color(0xFFD3CDBE), width: 0.5, ), ), child: const Icon( Icons.camera_alt_outlined, color: Color(0xFFB3AFA5), ), ), const SizedBox(width: 10), const Expanded( child: Text( '添加发票 / 实物照片(选填,仅存本机)', style: TextStyle( fontSize: 12, color: Color(0xFFB3AFA5), ), ), ), ], ), ), ], const SizedBox(height: 16), Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( side: const BorderSide(color: Color(0xFFE3D6B4)), padding: const EdgeInsets.symmetric(vertical: 12), ), child: const Text('取消'), ), ), const SizedBox(width: 10), Expanded( child: FilledButton( onPressed: () { final holding = GoldAssetHolding( id: 'h${DateTime.now().microsecondsSinceEpoch}', name: _nameForSelection(), metal: _metal, category: _cat, purity: _factorFor(_purity), purityLabel: _purity, weightGram: _gram, costAmount: double.tryParse(_costCtrl.text.trim()), purchaseDate: DateTime.tryParse( _dateCtrl.text.trim(), ), channel: _channelCtrl.text.trim().isEmpty ? null : _channelCtrl.text.trim(), note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(), createdAt: DateTime.now(), updatedAt: DateTime.now(), ); ref .read(assetPortfolioControllerProvider.notifier) .addHolding(holding); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已添加 · 总估值已重算')), ); }, child: const Text('保存'), ), ), ], ), ], ), ), ), ), ); } } class _Chip extends StatelessWidget { const _Chip({ required this.label, required this.selected, required this.onTap, }); final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), decoration: BoxDecoration( color: selected ? const Color(0xFFF6ECD6) : Colors.white, borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? const Color(0xFFE3D6B4) : const Color(0xFFECEAE3), width: 0.5, ), ), child: Text( label, style: TextStyle( fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? const Color(0xFF7A5B12) : const Color(0xFF5F5E5A), ), ), ), ); } } class _StepButton extends StatelessWidget { const _StepButton({required this.icon, required this.onTap}); final IconData icon; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: 34, height: 34, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFE3D6B4), width: 0.5), ), child: Icon(icon, size: 18, color: const Color(0xFFB5862A)), ), ); } } class _FieldRow extends StatelessWidget { const _FieldRow({required this.label, required this.child}); final String label; final Widget child; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFECEAE3), width: 0.5), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( label, style: const TextStyle(fontSize: 13, color: Color(0xFF8A8780)), ), const SizedBox(width: 10), Expanded(child: child), ], ), ); } }