diff --git a/flutter_monisuo/lib/ui/pages/mine/kyc_page.dart b/flutter_monisuo/lib/ui/pages/mine/kyc_page.dart new file mode 100644 index 0000000..884fe01 --- /dev/null +++ b/flutter_monisuo/lib/ui/pages/mine/kyc_page.dart @@ -0,0 +1,570 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../../core/theme/app_color_scheme.dart'; +import '../../../core/theme/app_spacing.dart'; +import '../../../providers/auth_provider.dart'; +import '../../components/glass_panel.dart'; +import '../../components/neon_glow.dart'; + +/// KYC 实名认证页面 +class KycPage extends StatefulWidget { + final bool returnToWithdraw; + + const KycPage({super.key, this.returnToWithdraw = false}); + + @override + State createState() => _KycPageState(); +} + +class _KycPageState extends State { + XFile? _frontFile; + XFile? _backFile; + Uint8List? _frontBytes; + Uint8List? _backBytes; + bool _isSubmitting = false; + + bool get _canSubmit => + _frontFile != null && _backFile != null && !_isSubmitting; + + final _picker = ImagePicker(); + + Future _pickImage(bool isFront) async { + final picked = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ); + if (picked != null) { + final bytes = await picked.readAsBytes(); + setState(() { + if (isFront) { + _frontFile = picked; + _frontBytes = bytes; + } else { + _backFile = picked; + _backBytes = bytes; + } + }); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colorScheme.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + '实名认证', + style: GoogleFonts.spaceGrotesk( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + leading: IconButton( + icon: Icon(LucideIcons.chevronLeft, color: colorScheme.onSurface), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SingleChildScrollView( + padding: AppSpacing.pagePadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 步骤指示器 + _buildStepIndicator(colorScheme), + SizedBox(height: AppSpacing.xl), + + // 主表单区 + GlassPanel( + padding: EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题区 + Row( + children: [ + Container( + padding: EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon( + LucideIcons.shieldCheck, + color: colorScheme.primary, + size: 22, + ), + ), + SizedBox(width: AppSpacing.md), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '身份验证', + style: GoogleFonts.spaceGrotesk( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + SizedBox(height: 2), + Text( + '上传身份证正反面完成实名认证', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + SizedBox(height: AppSpacing.xl), + + // 身份证正面上传区 + Text( + '身份证正面(人像面)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + SizedBox(height: AppSpacing.sm), + _buildUploadZone( + imageFile: _frontFile, + imageBytes: _frontBytes, + label: '人像面', + onTap: () => _pickImage(true), + colorScheme: colorScheme, + ), + SizedBox(height: AppSpacing.lg), + + // 身份证反面上传区 + Text( + '身份证反面(国徽面)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + SizedBox(height: AppSpacing.sm), + _buildUploadZone( + imageFile: _backFile, + imageBytes: _backBytes, + label: '国徽面', + onTap: () => _pickImage(false), + colorScheme: colorScheme, + ), + SizedBox(height: AppSpacing.xl), + + // 提交按钮 + SizedBox( + width: double.infinity, + child: NeonButton( + text: _isSubmitting ? '提交中...' : '提交认证', + type: NeonButtonType.primary, + onPressed: _canSubmit ? _submitKyc : null, + height: 48, + showGlow: _canSubmit, + ), + ), + ], + ), + ), + SizedBox(height: AppSpacing.lg), + + // 底部安全提示 + Container( + padding: EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColorScheme.up.withOpacity(0.06), + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: AppColorScheme.up.withOpacity(0.12), + ), + ), + child: Row( + children: [ + Icon( + LucideIcons.lock, + size: 16, + color: AppColorScheme.up, + ), + SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + '您的身份信息将被加密存储,仅用于身份验证', + style: TextStyle( + fontSize: 11, + color: AppColorScheme.up.withOpacity(0.8), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildStepIndicator(ColorScheme colorScheme) { + final isComplete = _frontFile != null && _backFile != null; + + return Row( + children: [ + _buildStepCircle( + number: '1', + label: '上传证件', + isActive: true, + isComplete: isComplete, + colorScheme: colorScheme, + ), + Expanded( + child: Container( + height: 2, + color: isComplete + ? AppColorScheme.up + : colorScheme.outlineVariant.withOpacity(0.2), + ), + ), + _buildStepCircle( + number: '2', + label: '认证完成', + isActive: false, + isComplete: false, + colorScheme: colorScheme, + isDone: isComplete, + ), + ], + ); + } + + Widget _buildStepCircle({ + required String number, + required String label, + required bool isActive, + required bool isComplete, + required ColorScheme colorScheme, + bool isDone = false, + }) { + final Color circleColor; + final Color textColor; + + if (isDone || isComplete) { + circleColor = AppColorScheme.up; + textColor = Colors.white; + } else if (isActive) { + circleColor = colorScheme.primary; + textColor = Colors.white; + } else { + circleColor = colorScheme.surfaceContainerHigh; + textColor = colorScheme.onSurfaceVariant; + } + + return Column( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: circleColor, + shape: BoxShape.circle, + ), + child: Center( + child: isComplete || isDone + ? Icon(LucideIcons.check, size: 16, color: textColor) + : Text( + number, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + ), + ), + SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + Widget _buildUploadZone({ + required XFile? imageFile, + required Uint8List? imageBytes, + required String label, + required VoidCallback onTap, + required ColorScheme colorScheme, + }) { + final hasImage = imageFile != null && imageBytes != null; + + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + height: 140, + decoration: BoxDecoration( + color: hasImage + ? AppColorScheme.up.withOpacity(0.06) + : colorScheme.surfaceContainerHigh.withOpacity(0.3), + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all( + color: hasImage + ? AppColorScheme.up.withOpacity(0.3) + : Colors.transparent, + ), + ), + child: hasImage + ? ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.xl), + child: Stack( + fit: StackFit.expand, + children: [ + // 图片预览 - 使用 memory 以兼容 Web + Image.memory( + imageBytes!, + fit: BoxFit.cover, + ), + // 底部渐变遮罩 + 文字 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.sm, horizontal: AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black54, + ], + ), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(AppRadius.xl), + bottomRight: Radius.circular(AppRadius.xl), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$label已选择', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + GestureDetector( + onTap: () { + setState(() { + if (label == '人像面') { + _frontFile = null; + _frontBytes = null; + } else { + _backFile = null; + _backBytes = null; + } + }); + }, + child: Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white24, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.x, + size: 14, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ) + : CustomPaint( + painter: _DashedBorderPainter( + color: colorScheme.onSurfaceVariant.withOpacity(0.2), + borderRadius: AppRadius.xl, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.camera, + size: 28, + color: colorScheme.onSurfaceVariant.withOpacity(0.5), + ), + SizedBox(height: AppSpacing.sm), + Text( + '点击上传$label', + style: TextStyle( + fontSize: 13, + color: colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + ), + SizedBox(height: 4), + Text( + '支持 JPG、PNG 格式', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant.withOpacity(0.4), + ), + ), + ], + ), + ), + ), + ); + } + + Future _submitKyc() async { + setState(() => _isSubmitting = true); + try { + final auth = context.read(); + final response = await auth.submitKyc( + _frontBytes!, + _backBytes!, + ); + + if (!mounted) return; + + if (response.success) { + showShadDialog( + context: context, + builder: (ctx) => ShadDialog.alert( + title: Row( + children: [ + NeonIcon( + icon: Icons.check_circle, + color: AppColorScheme.up, + size: 20, + ), + SizedBox(width: AppSpacing.sm), + const Text('认证成功'), + ], + ), + description: const Text('您的实名认证已通过,现在可以进行提现操作'), + actions: [ + ShadButton( + child: const Text('确定'), + onPressed: () { + Navigator.of(ctx).pop(); + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } else { + showShadDialog( + context: context, + builder: (ctx) => ShadDialog.alert( + title: const Text('认证失败'), + description: Text(response.message ?? '请稍后重试'), + actions: [ + ShadButton( + child: const Text('确定'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } + } catch (e) { + if (mounted) { + showShadDialog( + context: context, + builder: (ctx) => ShadDialog.alert( + title: const Text('认证失败'), + description: Text(e.toString()), + actions: [ + ShadButton( + child: const Text('确定'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } +} + +/// 虚线边框画笔 +class _DashedBorderPainter extends CustomPainter { + final Color color; + final double borderRadius; + + _DashedBorderPainter({ + required this.color, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + final dashWidth = 6.0; + final dashSpace = 4.0; + final r = borderRadius; + + final path = Path() + ..addRRect(RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + Radius.circular(r), + )); + + final metrics = path.computeMetrics(); + for (final metric in metrics) { + double distance = 0; + while (distance < metric.length) { + final end = (distance + dashWidth).clamp(0.0, metric.length); + canvas.drawPath( + metric.extractPath(distance, end), + paint, + ); + distance += dashWidth + dashSpace; + } + } + } + + @override + bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.borderRadius != borderRadius; + } +} diff --git a/uploads/kyc/17_back_1774801634029.jpg b/uploads/kyc/17_back_1774801634029.jpg new file mode 100644 index 0000000..8074cfb Binary files /dev/null and b/uploads/kyc/17_back_1774801634029.jpg differ diff --git a/uploads/kyc/17_front_1774801634029.jpg b/uploads/kyc/17_front_1774801634029.jpg new file mode 100644 index 0000000..8074cfb Binary files /dev/null and b/uploads/kyc/17_front_1774801634029.jpg differ