import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter_hooks/flutter_hooks.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/exchange_calculator_controller.dart'; import '../application/market_controller.dart'; import '../data/market_models.dart'; import '../../profile/application/price_color_theme.dart'; import '../../profile/application/profile_settings_controller.dart'; class MarketPage extends HookConsumerWidget { const MarketPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final market = ref.watch(marketControllerProvider); final exchange = ref.watch(exchangeCalculatorControllerProvider); final palette = priceColorPalette( ref.watch(profileSettingsControllerProvider).priceColorMode, ); final gramCtrl = useTextEditingController( text: exchange.gram.toStringAsFixed(2), ); final amountCtrl = useTextEditingController( text: exchange.amount.toStringAsFixed(2), ); final gramFocus = useFocusNode(); final amountFocus = useFocusNode(); final cs = ShadTheme.of(context).colorScheme; useEffect( () { if (!gramFocus.hasFocus) { gramCtrl.text = exchange.gram.toStringAsFixed( exchange.gram % 1 == 0 ? 0 : 2, ); } if (!amountFocus.hasFocus) { amountCtrl.text = exchange.amount.toStringAsFixed(2); } return null; }, [ exchange.gram, exchange.amount, exchange.selectedChannel.id, exchange.metal.id, ], ); 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 Text( '行情', style: TextStyle( fontSize: 17, fontWeight: FontWeight.w600, ), ), const Spacer(), GestureDetector( onTap: () => _showPriceAlertSheet(context, market.selectedMetal), child: Container( width: 30, height: 30, decoration: BoxDecoration( color: const Color(0xFFF7F4EE), borderRadius: BorderRadius.circular(999), ), child: const Icon( Icons.notifications_none_outlined, size: 18, color: Color(0xFF9A968D), ), ), ), ], ), const SizedBox(height: 14), Row( children: [ for (var i = 0; i < MetalType.values.length; i++) ...[ Expanded( child: _MetalTab( label: MetalType.values[i].label, selected: MetalType.values[i] == market.selectedMetal, onTap: () { final metal = MetalType.values[i]; ref .read(marketControllerProvider.notifier) .selectMetal(metal); ref .read( exchangeCalculatorControllerProvider.notifier, ) .selectMetal(metal); }, ), ), if (i != MetalType.values.length - 1) const SizedBox(width: 8), ], ], ), const SizedBox(height: 10), PrototypeCard( padding: const EdgeInsets.fromLTRB(14, 12, 14, 13), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '¥', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: cs.foreground, ), ), const SizedBox(width: 2), Text( formatCny( market.quote.spotPrice, ).replaceFirst('¥', ''), style: TextStyle( fontSize: 29, fontWeight: FontWeight.w600, color: cs.foreground, letterSpacing: -1, height: 1, ), ), const Padding( padding: EdgeInsets.only(left: 4, bottom: 2), child: Text( '元/克', style: TextStyle( fontSize: 12, color: Color(0xFF8A8780), ), ), ), ], ), const SizedBox(height: 6), Row( children: [ _ChangeChip( amount: market.quote.changeAmount, percent: market.quote.changePercent, upColor: Color(palette.up), downColor: Color(palette.down), upBackground: Color(palette.upBackground), downBackground: Color(palette.downBackground), ), const SizedBox(width: 8), Text( '报价 ${_timeLabel(market.quote.updatedAt)}', style: const TextStyle( fontSize: 12, color: Color(0xFF8A8780), ), ), const Spacer(), GestureDetector( onTap: () {}, child: const Icon( Icons.info_outline, size: 16, color: Color(0xFFB3AFA5), ), ), ], ), const SizedBox(height: 10), Row( children: [ for (final period in MarketPeriod.values) ...[ _PeriodPill( label: period.label, selected: period == market.period, onTap: () => ref .read(marketControllerProvider.notifier) .selectPeriod(period), ), if (period != MarketPeriod.values.last) const SizedBox(width: 6), ], ], ), const SizedBox(height: 8), GestureDetector( onTap: () => context.push( '${AppRoutes.marketKline}?metal=${market.selectedMetal.id}&period=${market.period.id}', ), child: SizedBox( height: 88, child: ClipRRect( borderRadius: BorderRadius.circular(10), child: PrototypeLineChart( values: market.points .map((p) => p.close) .toList(growable: false), ), ), ), ), const SizedBox(height: 10), Container(height: 0.5, color: cs.border), const SizedBox(height: 10), Row( children: [ const Text( '兑换试算', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), const Spacer(), const PrototypePill(label: '多渠道参考', selected: true), ], ), const SizedBox(height: 8), SizedBox( height: 32, child: ListView.separated( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), itemCount: exchange.channels.length, separatorBuilder: (context, index) => const SizedBox(width: 7), itemBuilder: (context, index) { final channel = exchange.channels[index]; return PrototypePill( label: channel.name, selected: channel.id == exchange.selectedChannel.id, onTap: () => ref .read( exchangeCalculatorControllerProvider .notifier, ) .selectChannel(channel.id), ); }, ), ), const SizedBox(height: 10), if (!exchange.swapped) ...[ _ExchangeField( controller: gramCtrl, focusNode: gramFocus, unit: exchange.metal.label, leading: '克', onChanged: (value) { final gram = double.tryParse(value) ?? 0; ref .read( exchangeCalculatorControllerProvider.notifier, ) .setGram(gram); }, ), const SizedBox(height: 8), Center( child: GestureDetector( onTap: () => ref .read( exchangeCalculatorControllerProvider.notifier, ) .toggleSwapped(), child: Container( width: 30, height: 30, decoration: BoxDecoration( color: Colors.white, border: Border.all( color: cs.border, width: 0.5, ), shape: BoxShape.circle, boxShadow: const [ BoxShadow( color: Color(0x1F281F0C), blurRadius: 7, offset: Offset(0, 2), ), ], ), child: const Icon( Icons.swap_vert, size: 16, color: Color(0xFFB5862A), ), ), ), ), const SizedBox(height: 8), _ExchangeField( controller: amountCtrl, focusNode: amountFocus, unit: 'CNY', leading: '¥', onChanged: (value) { final amount = double.tryParse(value) ?? 0; ref .read( exchangeCalculatorControllerProvider.notifier, ) .setAmount(amount); }, ), ] else ...[ _ExchangeField( controller: amountCtrl, focusNode: amountFocus, unit: 'CNY', leading: '¥', onChanged: (value) { final amount = double.tryParse(value) ?? 0; ref .read( exchangeCalculatorControllerProvider.notifier, ) .setAmount(amount); }, ), const SizedBox(height: 8), Center( child: GestureDetector( onTap: () => ref .read( exchangeCalculatorControllerProvider.notifier, ) .toggleSwapped(), child: Container( width: 30, height: 30, decoration: BoxDecoration( color: Colors.white, border: Border.all( color: cs.border, width: 0.5, ), shape: BoxShape.circle, boxShadow: const [ BoxShadow( color: Color(0x1F281F0C), blurRadius: 7, offset: Offset(0, 2), ), ], ), child: const Icon( Icons.swap_vert, size: 16, color: Color(0xFFB5862A), ), ), ), ), const SizedBox(height: 8), _ExchangeField( controller: gramCtrl, focusNode: gramFocus, unit: exchange.metal.label, leading: '克', onChanged: (value) { final gram = double.tryParse(value) ?? 0; ref .read( exchangeCalculatorControllerProvider.notifier, ) .setGram(gram); }, ), ], const SizedBox(height: 10), Text( '1 克 ${exchange.metal.label} = ${formatCny(exchange.selectedChannel.pricePerGram)} · ${exchange.selectedChannel.source} · ${_timeLabel(exchange.selectedChannel.updatedAt)} · 参考', style: const TextStyle( fontSize: 12, color: Color(0xFF6E6A60), ), ), const SizedBox(height: 5), Text( exchange.selectedChannel.hint, style: const TextStyle( fontSize: 11, color: Color(0xFFB3AFA5), height: 1.5, ), ), ], ), ), ], ), ), ], ), ); } } class _ExchangeField extends StatelessWidget { const _ExchangeField({ required this.controller, required this.focusNode, required this.unit, required this.leading, required this.onChanged, }); final TextEditingController controller; final FocusNode focusNode; final String unit; final String leading; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(13), border: Border.all(color: const Color(0xFFE3D6B4), width: 0.5), ), child: Row( children: [ Text( leading, style: const TextStyle(fontSize: 14, color: Color(0xFF8A8780)), ), const SizedBox(width: 8), Expanded( child: TextField( controller: controller, focusNode: focusNode, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), textAlign: TextAlign.right, decoration: const InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600), onChanged: onChanged, ), ), const SizedBox(width: 8), Text( unit, style: const TextStyle(fontSize: 14, color: Color(0xFF8A8780)), ), ], ), ); } } class _PeriodPill extends StatelessWidget { const _PeriodPill({ required this.label, required this.selected, required this.onTap, }); final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return PrototypePill(label: label, selected: selected, onTap: onTap); } } class _MetalTab extends StatelessWidget { const _MetalTab({ required this.label, required this.selected, required this.onTap, }); final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Container( height: 32, alignment: Alignment.center, decoration: BoxDecoration( color: selected ? const Color(0xFFF6ECD6) : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( color: selected ? const Color(0xFFE3D6B4) : const Color(0xFFE6E1D8), width: 0.5, ), ), child: Text( label, style: TextStyle( fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? const Color(0xFF7A5B12) : const Color(0xFF6E6A60), ), ), ), ); } } 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: 10, vertical: 4), decoration: BoxDecoration( color: positive ? upBackground : downBackground, borderRadius: BorderRadius.circular(999), ), child: Text( '${positive ? '↑' : '↓'}¥${amount.abs().toStringAsFixed(2)} · ${positive ? '+' : '-'}${percent.abs().toStringAsFixed(2)}%', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: positive ? upColor : downColor, ), ), ); } } Future _showPriceAlertSheet(BuildContext context, MetalType metal) async { var dir = 'up'; final controller = TextEditingController(text: '960.00'); try { await showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), builder: (ctx) { return StatefulBuilder( builder: (ctx, setState) { return SafeArea( child: Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(ctx).bottom, ), child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 38, height: 4, margin: const EdgeInsets.only(bottom: 14), decoration: BoxDecoration( color: const Color(0xFFE3DFD4), borderRadius: BorderRadius.circular(999), ), ), ), const Text( '设置到价提醒', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( '${metal.label} 到价时提醒你', style: const TextStyle( fontSize: 12, color: Color(0xFF8A8780), ), ), const SizedBox(height: 14), Row( children: [ Expanded( child: GestureDetector( onTap: () => setState(() => dir = 'up'), child: Container( padding: const EdgeInsets.symmetric( vertical: 10, ), decoration: BoxDecoration( color: dir == 'up' ? const Color(0xFFF6ECD6) : Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all( color: dir == 'up' ? const Color(0xFFE3D6B4) : const Color(0xFFECEAE3), width: 0.5, ), ), child: const Text( '涨到', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ), ), const SizedBox(width: 8), Expanded( child: GestureDetector( onTap: () => setState(() => dir = 'down'), child: Container( padding: const EdgeInsets.symmetric( vertical: 10, ), decoration: BoxDecoration( color: dir == 'down' ? const Color(0xFFF6ECD6) : Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all( color: dir == 'down' ? const Color(0xFFE3D6B4) : const Color(0xFFECEAE3), width: 0.5, ), ), child: const Text( '跌到', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ), ), ], ), const SizedBox(height: 14), Container( padding: const EdgeInsets.symmetric( horizontal: 13, vertical: 10, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(13), border: Border.all( color: const Color(0xFFE3D6B4), width: 0.5, ), ), child: Row( children: [ const Text( '¥', style: TextStyle( fontSize: 14, color: Color(0xFF8A8780), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: controller, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), textAlign: TextAlign.right, decoration: const InputDecoration( border: InputBorder.none, isDense: true, ), style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 8), const Text( '元/克', style: TextStyle( fontSize: 13, color: Color(0xFF8A8780), ), ), ], ), ), const SizedBox(height: 16), FilledButton( onPressed: () { Navigator.of(ctx).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '已设到价提醒 · $metal ${dir == 'up' ? '涨到' : '跌到'} ¥${controller.text}', ), ), ); }, child: const Text('完成'), ), ], ), ), ), ); }, ); }, ); } finally { controller.dispose(); } } String _timeLabel(DateTime value) { return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; }