diff --git a/lib/features/assets/presentation/asset_add_page.dart b/lib/features/assets/presentation/asset_add_page.dart index 60b4f90..ffbaba0 100644 --- a/lib/features/assets/presentation/asset_add_page.dart +++ b/lib/features/assets/presentation/asset_add_page.dart @@ -1,933 +1,12 @@ -import 'dart:io'; +import 'package:flutter/material.dart'; -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 'asset_form_page.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 { +class AssetAddPage extends StatelessWidget { 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), - ], - ), - ); + return const AssetFormPage.create(); } } diff --git a/lib/features/assets/presentation/asset_edit_page.dart b/lib/features/assets/presentation/asset_edit_page.dart index 0cf00ae..40d53e4 100644 --- a/lib/features/assets/presentation/asset_edit_page.dart +++ b/lib/features/assets/presentation/asset_edit_page.dart @@ -1,421 +1,21 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_ui/shadcn_ui.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'; +import 'asset_form_page.dart'; -class AssetEditPage extends ConsumerStatefulWidget { +class AssetEditPage extends ConsumerWidget { const AssetEditPage({super.key, required this.id}); final String id; @override - ConsumerState createState() => _AssetEditPageState(); -} - -class _AssetEditPageState extends ConsumerState { - late GoldAssetHolding _holding; - late final TextEditingController _weightCtrl; - late final TextEditingController _costCtrl; - late final TextEditingController _dateCtrl; - late final TextEditingController _channelCtrl; - double _gram = 0; - - @override - void initState() { - super.initState(); - _holding = ref - .read(assetPortfolioControllerProvider) + Widget build(BuildContext context, WidgetRef ref) { + final holding = ref + .watch(assetPortfolioControllerProvider) .holdings .map((item) => item.holding) - .firstWhere((item) => item.id == widget.id); - _gram = _holding.weightGram; - _weightCtrl = TextEditingController( - text: _holding.weightGram.toStringAsFixed( - _holding.weightGram % 1 == 0 ? 0 : 1, - ), - ); - _costCtrl = TextEditingController( - text: _holding.costAmount?.toStringAsFixed(0) ?? '', - ); - _dateCtrl = TextEditingController( - text: _holding.purchaseDate == null - ? '' - : '${_holding.purchaseDate!.year}-${_holding.purchaseDate!.month.toString().padLeft(2, '0')}-${_holding.purchaseDate!.day.toString().padLeft(2, '0')}', - ); - _channelCtrl = TextEditingController(text: _holding.channel ?? ''); - } - - Future pickDate(BuildContext context) async { - final today = DateTime.now(); - final parsed = _parseDate(_dateCtrl.text.trim()) ?? _holding.purchaseDate; - final initialDate = parsed == null - ? today - : parsed.isBefore(DateTime(2000)) - ? DateTime(2000) - : parsed.isAfter(today) - ? today - : parsed; - final picked = await showDatePicker( - context: context, - initialDate: initialDate, - firstDate: DateTime(2000), - lastDate: today, - initialEntryMode: DatePickerEntryMode.calendarOnly, - locale: const Locale('zh'), - ); - if (picked == null || !mounted) return; - 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); - } - - @override - void dispose() { - _weightCtrl.dispose(); - _costCtrl.dispose(); - _dateCtrl.dispose(); - _channelCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final summary = ref.watch(assetPortfolioControllerProvider); - final valuation = summary.holdings.firstWhere( - (item) => item.holding.id == widget.id, - ); - _holding = valuation.holding; - final cs = ShadTheme.of(context).colorScheme; - final market = ref.watch(marketControllerProvider); - final quote = market.quotes.firstWhere( - (item) => item.metal == _holding.metal, - ); - final value = _gram * _holding.purity * quote.spotPrice; - - return Scaffold( - backgroundColor: const Color(0xFFFBFAF7), - appBar: AppBar( - backgroundColor: const Color(0xFFFBFAF7), - surfaceTintColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - title: Text( - '编辑·${_holding.name}', - style: const 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.stretch, - children: [ - PrototypeCard( - child: Row( - children: [ - Text( - '金属 / 纯度', - style: TextStyle( - fontSize: 13, - color: cs.mutedForeground, - ), - ), - const Spacer(), - Text( - '${_holding.metal.label} · ${_holding.purityLabel}', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - const Text( - '克重(克)', - style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)), - ), - const SizedBox(height: 9), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - 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), - ), - ), - ), - ], - ), - ), - Slider( - min: 1, - max: 200, - value: _gram.clamp(1, 200), - onChanged: (value) => setState(() { - _gram = value; - _weightCtrl.text = value.toStringAsFixed( - value % 1 == 0 ? 0 : 1, - ); - }), - 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: Row( - children: [ - const Expanded( - child: Text( - '实时材料价值', - style: TextStyle( - fontSize: 13, - color: Color(0xFF7A5B12), - ), - ), - ), - Text( - formatCny(value), - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - const SizedBox(height: 14), - _FieldRow( - label: '买入成本(含工费)', - child: TextField( - controller: _costCtrl, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), - ], - textAlign: TextAlign.right, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - hintText: '选填', - ), - ), - ), - const SizedBox(height: 10), - _FieldRow( - label: '购买日期', - child: TextField( - controller: _dateCtrl, - readOnly: true, - onTap: () => pickDate(context), - textAlign: TextAlign.right, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - hintText: 'YYYY-MM-DD', - ), - ), - ), - 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: 16), - Row( - children: [ - Expanded( - child: FilledButton( - onPressed: () { - final grams = double.tryParse( - _weightCtrl.text.trim(), - ); - final cost = double.tryParse(_costCtrl.text.trim()); - final parsedDate = DateTime.tryParse( - _dateCtrl.text.trim(), - ); - ref - .read(assetPortfolioControllerProvider.notifier) - .updateHolding( - GoldAssetHolding( - id: _holding.id, - name: _holding.name, - metal: _holding.metal, - category: _holding.category, - purity: _holding.purity, - purityLabel: _holding.purityLabel, - weightGram: grams ?? _holding.weightGram, - images: _holding.images, - costAmount: cost, - purchaseDate: parsedDate, - channel: _channelCtrl.text.trim().isEmpty - ? null - : _channelCtrl.text.trim(), - createdAt: _holding.createdAt, - updatedAt: DateTime.now(), - status: _holding.status, - ), - ); - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已保存修改 · 总值已重算')), - ); - }, - child: const Text('保存修改'), - ), - ), - ], - ), - const SizedBox(height: 10), - Text( - '修改后会即时重算资产总值', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 11, color: cs.mutedForeground), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -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( - children: [ - Text( - label, - style: const TextStyle(fontSize: 13, color: Color(0xFF8A8780)), - ), - const SizedBox(width: 10), - Expanded(child: child), - ], - ), - ); - } -} - -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), - ), - ), - ), - ); + .firstWhere((item) => item.id == id); + return AssetFormPage.edit(holding: holding); } } diff --git a/lib/features/assets/presentation/asset_form_page.dart b/lib/features/assets/presentation/asset_form_page.dart new file mode 100644 index 0000000..cd50be8 --- /dev/null +++ b/lib/features/assets/presentation/asset_form_page.dart @@ -0,0 +1,1108 @@ +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'; + +enum AssetFormMode { create, edit } + +class AssetFormPage extends ConsumerStatefulWidget { + const AssetFormPage.create({super.key}) + : mode = AssetFormMode.create, + holding = null; + + const AssetFormPage.edit({super.key, required this.holding}) + : mode = AssetFormMode.edit; + + final AssetFormMode mode; + final GoldAssetHolding? holding; + + bool get isEdit => mode == AssetFormMode.edit; + + @override + ConsumerState createState() => _AssetFormPageState(); +} + +class _AssetFormPageState 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银'], + }; + + 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(); + + AssetCategory _cat = AssetCategory.bar; + MetalType _metal = MetalType.gold; + int _purityIndex = 0; + double _gram = 50; + bool _optionalExpanded = false; + + static const _maxImages = 9; + + GoldAssetHolding? get _holding => widget.holding; + bool get _isEdit => widget.isEdit; + bool get _isImageLimitReached => _images.length >= _maxImages; + String get _imageLimitText => '最多可添加 $_maxImages 张图片'; + + @override + void initState() { + super.initState(); + final holding = _holding; + if (holding != null) { + _optionalExpanded = true; + _cat = holding.category; + _metal = holding.metal; + _purityIndex = _purityIndexFor(holding); + _gram = holding.weightGram; + _costCtrl.text = holding.costAmount?.toStringAsFixed(0) ?? ''; + _dateCtrl.text = holding.purchaseDate == null + ? '' + : _formatDate(holding.purchaseDate!); + _channelCtrl.text = holding.channel ?? ''; + _noteCtrl.text = holding.note ?? ''; + _images.addAll(holding.images); + } + } + + @override + void dispose() { + _costCtrl.dispose(); + _noteCtrl.dispose(); + _dateCtrl.dispose(); + _channelCtrl.dispose(); + super.dispose(); + } + + int _purityIndexFor(GoldAssetHolding holding) { + final options = _metalPurities[holding.metal]!; + final index = options.indexOf(holding.purityLabel); + return index < 0 ? 0 : index; + } + + List get _purityOptions => _metalPurities[_metal]!; + + String get _purity => + _purityOptions[_purityIndex.clamp(0, _purityOptions.length - 1)]; + + 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饰品', + }; + } + + 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 pickDate(BuildContext context) async { + final today = DateTime.now(); + final parsed = _parseDate(_dateCtrl.text.trim()) ?? + _holding?.purchaseDate ?? + today; + final initialDate = parsed.isBefore(DateTime(2000)) + ? DateTime(2000) + : parsed.isAfter(today) + ? today + : parsed; + final picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(2000), + lastDate: today, + initialEntryMode: DatePickerEntryMode.calendarOnly, + locale: const Locale('zh'), + ); + if (picked == null || !mounted) return; + setState(() { + _dateCtrl.text = _formatDate(picked); + }); + } + + 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 = _maxImages - _images.length; + 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 _addPickedFile(XFile file) async { + if (_images.length >= _maxImages) return; + final image = await _assetImageFromXFile(file); + if (!mounted) return; + setState(() => _images.add(image)); + } + + 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('去设置'), + ), + ], + ), + ); + } + + void _removeImage(String localId) { + setState(() => _images.removeWhere((item) => item.localId == localId)); + } + + void _save() { + final cost = double.tryParse(_costCtrl.text.trim()); + final parsedDate = DateTime.tryParse(_dateCtrl.text.trim()); + final channel = _channelCtrl.text.trim(); + final note = _noteCtrl.text.trim(); + final messenger = ScaffoldMessenger.maybeOf(context); + + if (_isEdit) { + final holding = _holding!; + ref + .read(assetPortfolioControllerProvider.notifier) + .updateHolding( + GoldAssetHolding( + id: holding.id, + name: holding.name, + metal: holding.metal, + category: holding.category, + purity: holding.purity, + purityLabel: holding.purityLabel, + weightGram: _gram, + images: List.unmodifiable(_images), + costAmount: cost, + purchaseDate: parsedDate, + channel: channel.isEmpty ? null : channel, + note: note.isEmpty ? null : note, + createdAt: holding.createdAt, + updatedAt: DateTime.now(), + status: holding.status, + ), + ); + messenger?.showSnackBar( + const SnackBar(content: Text('已保存修改 · 总值已重算')), + ); + Navigator.of(context).pop(); + return; + } + + final holding = GoldAssetHolding( + id: 'h${DateTime.now().microsecondsSinceEpoch}', + name: _nameForSelection(), + metal: _metal, + category: _cat, + purity: _factorFor(_purity), + purityLabel: _purity, + weightGram: _gram, + costAmount: cost, + purchaseDate: parsedDate, + channel: channel.isEmpty ? null : channel, + note: note.isEmpty ? null : note, + images: List.unmodifiable(_images), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + ref.read(assetPortfolioControllerProvider.notifier).addHolding(holding); + messenger?.showSnackBar( + const SnackBar(content: Text('已添加 · 总估值已重算')), + ); + Navigator.of(context).pop(); + } + + 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), + ), + ), + ), + ], + ); + } + + 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 Icon( + Icons.check_circle_outline, + color: Color(0xFFB3AFA5), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _imageLimitText, + style: const TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)), + ), + ], + ], + ), + ); + } + + Widget _buildCreateBody(BuildContext context, ShadColorScheme cs) { + final market = ref.watch(marketControllerProvider); + final quote = market.quotes.firstWhere((item) => item.metal == _metal); + final value = _gram * _factorFor(_purity) * quote.spotPrice; + + return 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: () => setState(() { + _purityIndex = (_purityIndex + 1) % _purityOptions.length; + }), + 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: Row( + children: [ + const Expanded( + child: Text( + '实时材料价值', + style: TextStyle(fontSize: 13, color: Color(0xFF7A5B12)), + ), + ), + Text( + formatCny(value), + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600), + ), + ], + ), + ), + const SizedBox(height: 14), + GestureDetector( + onTap: () => setState(() => _optionalExpanded = !_optionalExpanded), + 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( + _optionalExpanded ? Icons.expand_less : Icons.expand_more, + color: cs.mutedForeground, + size: 18, + ), + ], + ), + ), + ), + if (_optionalExpanded) ...[ + 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), + ], + ); + } + + Widget _buildEditBody(BuildContext context, ShadColorScheme cs) { + final holding = _holding!; + final market = ref.watch(marketControllerProvider); + final quote = market.quotes.firstWhere((item) => item.metal == holding.metal); + final value = _gram * holding.purity * quote.spotPrice; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + PrototypeCard( + child: Row( + children: [ + Text( + '金属 / 纯度', + style: TextStyle(fontSize: 13, color: cs.mutedForeground), + ), + const Spacer(), + Text( + '${holding.metal.label} · ${holding.purityLabel}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ], + ), + ), + const SizedBox(height: 16), + const Text('克重(克)', style: TextStyle(fontSize: 13, color: Color(0xFF8A8780))), + const SizedBox(height: 9), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + 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)), + ), + ), + ], + ), + ), + 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: Row( + children: [ + const Expanded( + child: Text( + '实时材料价值', + style: TextStyle(fontSize: 13, color: Color(0xFF7A5B12)), + ), + ), + Text( + formatCny(value), + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600), + ), + ], + ), + ), + const SizedBox(height: 14), + _FieldRow( + label: '买入成本(含工费)', + child: TextField( + controller: _costCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), + ], + textAlign: TextAlign.right, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: '选填', + ), + ), + ), + const SizedBox(height: 10), + _FieldRow( + label: '购买日期', + child: TextField( + controller: _dateCtrl, + readOnly: true, + onTap: () => pickDate(context), + textAlign: TextAlign.right, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: 'YYYY-MM-DD', + ), + ), + ), + 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, + textAlign: TextAlign.right, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: '点击填写', + ), + ), + ), + const SizedBox(height: 10), + _buildImagesSection(), + const SizedBox(height: 16), + ], + ); + } + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + + return Scaffold( + backgroundColor: const Color(0xFFFBFAF7), + appBar: AppBar( + backgroundColor: const Color(0xFFFBFAF7), + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + title: Text( + _isEdit ? '编辑·${_holding!.name}' : '添加资产', + style: const 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.stretch, + children: [ + if (_isEdit) _buildEditBody(context, cs) + else _buildCreateBody(context, cs), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: _save, + child: Text(_isEdit ? '保存修改' : '保存'), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + _isEdit ? '修改后会即时重算资产总值' : '添加后会即时重算资产总值', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +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), + ], + ), + ); + } +} diff --git a/lib/features/assets/presentation/holding_detail_page.dart b/lib/features/assets/presentation/holding_detail_page.dart index aded490..ce7e19e 100644 --- a/lib/features/assets/presentation/holding_detail_page.dart +++ b/lib/features/assets/presentation/holding_detail_page.dart @@ -239,11 +239,19 @@ class HoldingDetailPage extends ConsumerWidget { ), const PrototypeDivider(margin: EdgeInsets.zero), _InfoRow(label: '购买渠道', value: holding.channel ?? '未填'), + const PrototypeDivider(margin: EdgeInsets.zero), + _InfoRow( + label: '备注', + value: holding.note == null || + holding.note!.trim().isEmpty + ? '未填' + : holding.note!.trim(), + ), ], ), ), const SizedBox(height: 12), - _buildImageSection(context, ref, holding), + _buildImageSection(context, holding), const SizedBox(height: 12), Row( children: [ @@ -370,7 +378,6 @@ class HoldingDetailPage extends ConsumerWidget { Widget _buildImageSection( BuildContext context, - WidgetRef ref, GoldAssetHolding holding, ) { final images = holding.images; @@ -397,7 +404,6 @@ class HoldingDetailPage extends ConsumerWidget { for (var index = 0; index < images.length; index++) _buildDetailImageTile( context, - ref, holding, images[index], index, @@ -411,38 +417,19 @@ class HoldingDetailPage extends ConsumerWidget { Widget _buildDetailImageTile( BuildContext context, - WidgetRef ref, GoldAssetHolding holding, AssetImage image, int index, ) { return GestureDetector( onTap: () => _openImagePreview(context, holding.images, index), - onLongPress: () => _confirmDeleteImage(context, ref, holding, image), child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - Image.file( - File(image.localPath), - width: 84, - height: 84, - fit: BoxFit.cover, - ), - Positioned( - right: 4, - top: 4, - child: Container( - width: 20, - height: 20, - decoration: const BoxDecoration( - color: Color(0xAA000000), - shape: BoxShape.circle, - ), - child: const Icon(Icons.remove, size: 14, color: Colors.white), - ), - ), - ], + child: Image.file( + File(image.localPath), + width: 84, + height: 84, + fit: BoxFit.cover, ), ), ); @@ -463,39 +450,6 @@ class HoldingDetailPage extends ConsumerWidget { ); } - void _confirmDeleteImage( - BuildContext context, - WidgetRef ref, - GoldAssetHolding holding, - AssetImage image, - ) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('删除照片'), - content: const Text('确认删除这张凭证照片吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('取消'), - ), - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - final updated = holding.copyWith( - images: List.unmodifiable( - holding.images.where((item) => item.localId != image.localId).toList(), - ), - updatedAt: DateTime.now(), - ); - ref.read(assetPortfolioControllerProvider.notifier).updateHolding(updated); - }, - child: const Text('删除'), - ), - ], - ), - ); - } } class _ActionMenuTile extends StatelessWidget { @@ -609,233 +563,6 @@ class _ChangeChip extends StatelessWidget { } } -class _EditHoldingSheet extends StatefulWidget { - const _EditHoldingSheet({required this.holding, required this.onSave}); - - final GoldAssetHolding holding; - final ValueChanged onSave; - - @override - State<_EditHoldingSheet> createState() => _EditHoldingSheetState(); -} - -class _EditHoldingSheetState extends State<_EditHoldingSheet> { - late final TextEditingController _weightCtrl; - late final TextEditingController _costCtrl; - late final TextEditingController _dateCtrl; - late final TextEditingController _channelCtrl; - late final TextEditingController _noteCtrl; - - @override - void initState() { - super.initState(); - _weightCtrl = TextEditingController( - text: widget.holding.weightGram.toStringAsFixed( - widget.holding.weightGram % 1 == 0 ? 0 : 1, - ), - ); - _costCtrl = TextEditingController( - text: widget.holding.costAmount?.toStringAsFixed(0) ?? '', - ); - _dateCtrl = TextEditingController( - text: widget.holding.purchaseDate == null - ? '' - : '${widget.holding.purchaseDate!.year}-${widget.holding.purchaseDate!.month.toString().padLeft(2, '0')}-${widget.holding.purchaseDate!.day.toString().padLeft(2, '0')}', - ); - _channelCtrl = TextEditingController(text: widget.holding.channel ?? ''); - _noteCtrl = TextEditingController(text: widget.holding.note ?? ''); - } - - @override - void dispose() { - _weightCtrl.dispose(); - _costCtrl.dispose(); - _dateCtrl.dispose(); - _channelCtrl.dispose(); - _noteCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - 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.stretch, - 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), - _FieldRow( - label: '金属 / 纯度', - child: Text( - '${widget.holding.metal.label} · ${widget.holding.purityLabel}', - textAlign: TextAlign.right, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(height: 10), - _FieldRow( - label: '克重(克)', - child: TextField( - controller: _weightCtrl, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - textAlign: TextAlign.right, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - ), - ), - ), - const SizedBox(height: 10), - _FieldRow( - label: '买入成本(含工费)', - child: TextField( - controller: _costCtrl, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - textAlign: TextAlign.right, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - hintText: '选填', - ), - ), - ), - const SizedBox(height: 10), - _FieldRow( - label: '购买日期', - child: TextField( - controller: _dateCtrl, - textAlign: TextAlign.right, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - hintText: 'YYYY-MM-DD', - ), - ), - ), - const SizedBox(height: 10), - _FieldRow( - label: '购买渠道', - child: TextField( - controller: _channelCtrl, - textAlign: TextAlign.right, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - hintText: '选填', - ), - ), - ), - const SizedBox(height: 10), - _FieldRow( - label: '备注', - child: TextField( - controller: _noteCtrl, - textAlign: TextAlign.right, - decoration: const InputDecoration( - border: InputBorder.none, - isDense: true, - hintText: '点击填写', - ), - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - final grams = double.tryParse(_weightCtrl.text.trim()); - final cost = double.tryParse(_costCtrl.text.trim()); - final parsedDate = DateTime.tryParse( - _dateCtrl.text.trim(), - ); - widget.onSave( - GoldAssetHolding( - id: widget.holding.id, - name: widget.holding.name, - metal: widget.holding.metal, - category: widget.holding.category, - purity: widget.holding.purity, - purityLabel: widget.holding.purityLabel, - weightGram: grams ?? widget.holding.weightGram, - costAmount: cost, - purchaseDate: parsedDate, - channel: _channelCtrl.text.trim().isEmpty - ? null - : _channelCtrl.text.trim(), - note: _noteCtrl.text.trim().isEmpty - ? null - : _noteCtrl.text.trim(), - createdAt: widget.holding.createdAt, - updatedAt: DateTime.now(), - status: widget.holding.status, - ), - ); - }, - child: const Text('保存修改'), - ), - ), - const SizedBox(height: 10), - Text( - '修改后会即时重算资产总值', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 11, color: cs.mutedForeground), - ), - ], - ), - ), - ), - ), - ); - } -} - class _FieldRow extends StatelessWidget { const _FieldRow({required this.label, required this.child});