This commit is contained in:
sion
2026-03-30 00:31:47 +08:00
parent 08623d7a87
commit 20ffcd2d7e
3 changed files with 570 additions and 0 deletions

View 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;
}
}