import 'dart:io'; import 'package:flutter/material.dart' hide AssetImage; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:uuid/uuid.dart'; import 'package:wechat_assets_picker/wechat_assets_picker.dart'; import '../../../common/domain/metal_type.dart'; import '../../../common/domain/money.dart'; import '../../../common/widgets/prototype_ui.dart'; import '../../market/application/market_controller.dart'; import '../application/asset_portfolio_controller.dart'; import '../data/asset_models.dart'; class AssetAddPage extends ConsumerStatefulWidget { const AssetAddPage({super.key}); @override ConsumerState createState() => _AssetAddPageState(); } class _AssetAddPageState extends ConsumerState { 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: '银行'); final _picker = ImagePicker(); final _images = []; final _uuid = const Uuid(); static const _maxImages = 9; int get _remainingImageSlots => _maxImages - _images.length; bool get _isImageLimitReached => _remainingImageSlots <= 0; String get _imageLimitText => '最多可添加 $_maxImages 张图片'; @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饰品', }; } Future pickDate(BuildContext context) async { final today = DateTime.now(); final parsed = _parseDate(_dateCtrl.text.trim()); final initialDate = parsed == null ? today : parsed.isBefore(DateTime(2000)) ? DateTime(2000) : parsed.isAfter(today) ? today : parsed; final DateTime? picked = await showDatePicker( context: context, initialDate: initialDate, firstDate: DateTime(2000), lastDate: today, initialEntryMode: DatePickerEntryMode.calendarOnly, locale: const Locale('zh'), ); if (picked != null) { setState(() { _dateCtrl.text = _formatDate(picked); }); } } String _formatDate(DateTime value) { return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; } DateTime? _parseDate(String value) { if (value.isEmpty) return null; return DateTime.tryParse(value); } Future _showImageSourceSheet() async { if (!mounted) return; showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(18)), ), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), Container( width: 38, height: 4, decoration: BoxDecoration( color: const Color(0xFFE3DFD4), borderRadius: BorderRadius.circular(999), ), ), const SizedBox(height: 14), ListTile( leading: const Icon(Icons.camera_alt_outlined), title: const Text('拍照'), onTap: () { Navigator.of(ctx).pop(); _handleTakePhoto(); }, ), ListTile( leading: const Icon(Icons.photo_library_outlined), title: const Text('从相册选择'), onTap: () { Navigator.of(ctx).pop(); _handlePickFromGallery(); }, ), const SizedBox(height: 8), ListTile( title: const Text( '取消', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFF8A8780)), ), onTap: () => Navigator.of(ctx).pop(), ), ], ), ), ); } Future _handleTakePhoto() async { final granted = await _requestCameraPermission(); if (!granted) { _showPermissionDeniedDialog('相机'); return; } final file = await _picker.pickImage( source: ImageSource.camera, imageQuality: 80, maxWidth: 2048, ); if (file == null) return; await _addPickedFile(file); } Future _handlePickFromGallery() async { final availableSlots = _remainingImageSlots; if (availableSlots <= 0) return; final assets = await AssetPicker.pickAssets( context, pickerConfig: AssetPickerConfig( maxAssets: availableSlots, requestType: RequestType.image, themeColor: const Color(0xFFB5862A), ), ); if (assets == null || assets.isEmpty) { return; } final pickedImages = []; for (final asset in assets.take(availableSlots)) { final file = await asset.file; if (file == null) continue; pickedImages.add(await _assetImageFromSourceFile(file)); } if (pickedImages.isEmpty || !mounted) return; setState(() { _images.addAll(pickedImages); }); } Future _assetImageFromXFile(XFile file) { return _assetImageFromSourceFile(File(file.path)); } Future _assetImageFromSourceFile(File sourceFile) async { final localId = 'img${DateTime.now().microsecondsSinceEpoch}${_uuid.v4().split('-').first}'; final extension = _extensionForPath(sourceFile.path); final directory = await getApplicationSupportDirectory(); final imageDirectory = Directory('${directory.path}/asset_images'); if (!await imageDirectory.exists()) { await imageDirectory.create(recursive: true); } final copiedFile = await sourceFile.copy( '${imageDirectory.path}/$localId$extension', ); return AssetImage( localId: localId, localPath: copiedFile.path, createdAt: DateTime.now(), ); } String _extensionForPath(String path) { final fileName = path.split(Platform.pathSeparator).last; final dotIndex = fileName.lastIndexOf('.'); if (dotIndex <= 0 || dotIndex == fileName.length - 1) { return '.jpg'; } return fileName.substring(dotIndex); } Future _requestCameraPermission() async { final status = await Permission.camera.status; if (status.isGranted) return true; final result = await Permission.camera.request(); return result.isGranted; } void _showPermissionDeniedDialog(String target) { if (!mounted) return; showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('权限不足'), content: Text('无法访问$target,请在系统设置中开启权限。'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消'), ), TextButton( onPressed: () { Navigator.of(ctx).pop(); openAppSettings(); }, child: const Text('去设置'), ), ], ), ); } Future _addPickedFile(XFile file) async { if (_images.length >= _maxImages) return; final image = await _assetImageFromXFile(file); if (!mounted) return; setState(() { _images.add(image); }); } void _removeImage(String localId) { setState(() { _images.removeWhere((item) => item.localId == localId); }); } Widget _buildImagesSection() { return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFECEAE3), width: 0.5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_images.isEmpty) ...[ GestureDetector( onTap: _showImageSourceSheet, 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: 8), const Text( '支持发票、收据、金条证书、实物照片。', style: TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)), ), ] else ...[ const Text( '凭证照片', style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ for (final image in _images) _buildImageTile(image), if (!_isImageLimitReached) GestureDetector( onTap: _showImageSourceSheet, child: Container( width: 84, height: 84, decoration: BoxDecoration( color: const Color(0xFFF6ECD6), borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon(Icons.add, color: Color(0xFFB5862A)), SizedBox(height: 5), Text( '继续补充', textAlign: TextAlign.center, style: TextStyle( fontSize: 11, color: Color(0xFFB5862A), ), ), ], ), ), ), if (_isImageLimitReached) Container( width: 84, height: 84, decoration: BoxDecoration( color: const Color(0xFFF2F0EA), borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFFE3DFD4), width: 0.5, ), ), alignment: Alignment.center, child: const Text( '已满 9 张', textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: Color(0xFF8A8780)), ), ), ], ), const SizedBox(height: 10), Text( _imageLimitText, style: const TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)), ), ], ], ), ); } Widget _buildImageTile(AssetImage image) { return Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.file( File(image.localPath), width: 84, height: 84, fit: BoxFit.cover, ), ), Positioned( top: 6, right: 6, child: GestureDetector( onTap: () => _removeImage(image.localId), child: Container( width: 22, height: 22, decoration: const BoxDecoration( color: Color(0xAA000000), shape: BoxShape.circle, ), child: const Icon(Icons.close, size: 14, color: Colors.white), ), ), ), ], ); } @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 Scaffold( backgroundColor: const Color(0xFFFBFAF7), appBar: AppBar( backgroundColor: const Color(0xFFFBFAF7), surfaceTintColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, centerTitle: true, title: const Text( '添加资产', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), leading: IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.chevron_left, size: 20), ), ), body: SafeArea( top: false, child: ListView( padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), children: [ PrototypeFrame( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ 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, ), inputFormatters: [ FilteringTextInputFormatter.allow( RegExp(r'[0-9.]'), ), ], decoration: const InputDecoration( border: InputBorder.none, isDense: true, hintText: '选填', ), textAlign: TextAlign.right, ), ), const SizedBox(height: 10), _FieldRow( label: '购买日期', child: TextField( controller: _dateCtrl, readOnly: true, onTap: () => pickDate(context), decoration: const InputDecoration( border: InputBorder.none, isDense: true, hintText: 'YYYY-MM-DD', ), 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), _buildImagesSection(), const SizedBox(height: 16), ], SizedBox( width: double.infinity, 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(), images: List.unmodifiable(_images), 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), ], ), ); } }