111
This commit is contained in:
570
flutter_monisuo/lib/ui/pages/mine/kyc_page.dart
Normal file
570
flutter_monisuo/lib/ui/pages/mine/kyc_page.dart
Normal file
@@ -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<KycPage> createState() => _KycPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KycPageState extends State<KycPage> {
|
||||||
|
XFile? _frontFile;
|
||||||
|
XFile? _backFile;
|
||||||
|
Uint8List? _frontBytes;
|
||||||
|
Uint8List? _backBytes;
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
bool get _canSubmit =>
|
||||||
|
_frontFile != null && _backFile != null && !_isSubmitting;
|
||||||
|
|
||||||
|
final _picker = ImagePicker();
|
||||||
|
|
||||||
|
Future<void> _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<void> _submitKyc() async {
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
try {
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
uploads/kyc/17_back_1774801634029.jpg
Normal file
BIN
uploads/kyc/17_back_1774801634029.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
BIN
uploads/kyc/17_front_1774801634029.jpg
Normal file
BIN
uploads/kyc/17_front_1774801634029.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
Reference in New Issue
Block a user