diff --git a/frontend/mobile-app/android/app/src/main/AndroidManifest.xml b/frontend/mobile-app/android/app/src/main/AndroidManifest.xml index 09bf6b63..d59fc668 100644 --- a/frontend/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/frontend/mobile-app/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,11 @@ android:name="flutterEmbedding" android:value="2" /> + + + { return _isValidReferralCode(_referralCodeController.text); } + /// 导入助记词恢复账号 + void _importMnemonic() { + debugPrint('[GuidePage] _importMnemonic - 跳转到导入助记词页面'); + Navigator.of(context).pushNamed(RoutePaths.importMnemonic); + } + /// 保存推荐码并继续下一步 Future _saveReferralCodeAndProceed() async { if (!_canProceed) return; @@ -614,23 +620,28 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> { child: Row( children: [ Expanded( - child: TextField( - controller: _referralCodeController, - decoration: InputDecoration( - hintText: '请输入推荐码 / 序列号', - hintStyle: TextStyle( - fontSize: 16.sp, - color: Colors.black.withValues(alpha: 0.4), + child: GestureDetector( + onTap: _openQrScanner, + child: AbsorbPointer( + child: TextField( + controller: _referralCodeController, + readOnly: true, + decoration: InputDecoration( + hintText: '点击扫描推荐码', + hintStyle: TextStyle( + fontSize: 16.sp, + color: Colors.black.withValues(alpha: 0.4), + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: TextStyle( + fontSize: 16.sp, + color: Colors.black, + ), ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, ), - style: TextStyle( - fontSize: 16.sp, - color: Colors.black, - ), - cursorColor: Colors.black, ), ), // 扫码按钮 @@ -679,6 +690,70 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> { ], ), ), + SizedBox(height: 32.h), + // 分隔线 + Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Colors.white.withValues(alpha: 0.3), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Text( + '或', + style: TextStyle( + fontSize: 14.sp, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ), + Expanded( + child: Container( + height: 1, + color: Colors.white.withValues(alpha: 0.3), + ), + ), + ], + ), + SizedBox(height: 24.h), + // 导入助记词入口 + GestureDetector( + onTap: _importMnemonic, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 14.h), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.restore, + size: 20.sp, + color: Colors.white, + ), + SizedBox(width: 8.w), + Text( + '已有账号?导入助记词恢复', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ], + ), + ), + ), ], ); } diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart index d2b906f6..40e0c95f 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart @@ -196,55 +196,14 @@ class _SplashPageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ // Logo - Container( + Image.asset( + 'assets/images/logo/app_icon.png', width: 128, - height: 152, - decoration: BoxDecoration( - color: const Color(0xFF2D2A26), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 64, - height: 48, - decoration: BoxDecoration( - color: const Color(0xFFD4A84B), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.workspace_premium, - size: 32, - color: Color(0xFF2D2A26), - ), - ), - const SizedBox(height: 16), - const Text( - 'DURIAN', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - letterSpacing: 2, - color: Color(0xFFD4A84B), - ), - ), - const SizedBox(height: 2), - const Text( - 'QUEEN', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w400, - letterSpacing: 1.5, - color: Color(0xFFD4A84B), - ), - ), - ], - ), + height: 128, ), const SizedBox(height: 24), const Text( - '榴莲女皇', + '榴莲皇后', style: TextStyle( fontSize: 32, fontFamily: 'Noto Sans SC', diff --git a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart index aa54cd6c..2ba8bdfb 100644 --- a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart +++ b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../routes/route_paths.dart'; @@ -195,6 +198,47 @@ class _WithdrawUsdtPageState extends ConsumerState { ); } + /// 打开二维码扫描页面 + Future _openQrScanner() async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const _AddressQrScannerPage(), + ), + ); + if (result != null && result.isNotEmpty) { + // 从扫描结果中提取地址 + final address = _extractAddress(result); + setState(() { + _addressController.text = address; + }); + } + } + + /// 从扫描结果中提取地址 + /// 支持以下格式: + /// - 纯地址: 0x1234... 或 kava1... + /// - EIP-681 格式: ethereum:0x1234... + /// - 带参数: ethereum:0x1234...?value=100 + String _extractAddress(String scannedData) { + String data = scannedData.trim(); + + // 处理 EIP-681 格式 (ethereum:0x... 或 kava:kava1...) + if (data.contains(':')) { + final colonIndex = data.indexOf(':'); + data = data.substring(colonIndex + 1); + + // 移除可能的参数 (?value=... 或 @chainId) + if (data.contains('?')) { + data = data.substring(0, data.indexOf('?')); + } + if (data.contains('@')) { + data = data.substring(0, data.indexOf('@')); + } + } + + return data.trim(); + } + /// 格式化数字(添加千分位) String _formatNumber(double number) { final parts = number.toStringAsFixed(2).split('.'); @@ -545,16 +589,11 @@ class _WithdrawUsdtPageState extends ConsumerState { ), suffixIcon: IconButton( icon: const Icon( - Icons.content_paste, + Icons.qr_code_scanner, color: Color(0xFF8B5A2B), - size: 20, + size: 22, ), - onPressed: () async { - final data = await Clipboard.getData('text/plain'); - if (data?.text != null) { - _addressController.text = data!.text!; - } - }, + onPressed: _openQrScanner, ), ), ), @@ -836,3 +875,366 @@ class _WithdrawUsdtPageState extends ConsumerState { ); } } + +/// 地址二维码扫描页面 +class _AddressQrScannerPage extends StatefulWidget { + const _AddressQrScannerPage(); + + @override + State<_AddressQrScannerPage> createState() => _AddressQrScannerPageState(); +} + +class _AddressQrScannerPageState extends State<_AddressQrScannerPage> { + MobileScannerController? _controller; + bool _hasScanned = false; + bool _torchOn = false; + bool _isProcessingImage = false; + + @override + void initState() { + super.initState(); + _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_hasScanned) return; + + final List barcodes = capture.barcodes; + for (final barcode in barcodes) { + if (barcode.rawValue != null && barcode.rawValue!.isNotEmpty) { + _hasScanned = true; + Navigator.of(context).pop(barcode.rawValue); + return; + } + } + } + + Future _toggleTorch() async { + await _controller?.toggleTorch(); + setState(() { + _torchOn = !_torchOn; + }); + } + + /// 从相册选择图片并扫描二维码 + Future _pickImageAndScan() async { + if (_isProcessingImage || _hasScanned) return; + + setState(() { + _isProcessingImage = true; + }); + + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + + if (image == null) { + setState(() { + _isProcessingImage = false; + }); + return; + } + + // 使用 MobileScannerController 分析图片 + final BarcodeCapture? result = await _controller?.analyzeImage(image.path); + + if (result != null && result.barcodes.isNotEmpty) { + for (final barcode in result.barcodes) { + if (barcode.rawValue != null && barcode.rawValue!.isNotEmpty) { + _hasScanned = true; + if (mounted) { + Navigator.of(context).pop(barcode.rawValue); + } + return; + } + } + } + + // 未识别到二维码 + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '未能识别图片中的二维码,请重新选择', + style: TextStyle(fontSize: 14.sp), + ), + backgroundColor: const Color(0xFF6F6354), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(16.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ); + } + } catch (e) { + debugPrint('扫描图片失败: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '图片扫描失败,请重试', + style: TextStyle(fontSize: 14.sp), + ), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(16.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isProcessingImage = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + '扫描钱包地址', + style: TextStyle( + fontSize: 18.sp, + color: Colors.white, + ), + ), + centerTitle: true, + actions: [ + IconButton( + icon: Icon( + _torchOn ? Icons.flash_on : Icons.flash_off, + color: Colors.white, + ), + onPressed: _toggleTorch, + ), + ], + ), + body: Stack( + children: [ + // 扫描区域 + MobileScanner( + controller: _controller, + onDetect: _onDetect, + ), + // 扫描框遮罩 + _buildScanOverlay(), + // 底部区域:提示文字和相册按钮 + Positioned( + bottom: 60.h, + left: 0, + right: 0, + child: Column( + children: [ + Text( + '将钱包地址二维码放入框内', + style: TextStyle( + fontSize: 14.sp, + color: Colors.white70, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 32.h), + // 相册按钮 + GestureDetector( + onTap: _isProcessingImage ? null : _pickImageAndScan, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(24.r), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isProcessingImage) + SizedBox( + width: 18.sp, + height: 18.sp, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + else + Icon( + Icons.photo_library_outlined, + size: 18.sp, + color: Colors.white, + ), + SizedBox(width: 8.w), + Text( + _isProcessingImage ? '识别中...' : '从相册选择', + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建扫描框遮罩 + Widget _buildScanOverlay() { + return LayoutBuilder( + builder: (context, constraints) { + final scanAreaSize = 250.w; + final left = (constraints.maxWidth - scanAreaSize) / 2; + final top = (constraints.maxHeight - scanAreaSize) / 2 - 50.h; + + return Stack( + children: [ + // 半透明背景 + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.6), + BlendMode.srcOut, + ), + child: Stack( + children: [ + Container( + decoration: const BoxDecoration( + color: Colors.black, + backgroundBlendMode: BlendMode.dstOut, + ), + ), + Positioned( + left: left, + top: top, + child: Container( + width: scanAreaSize, + height: scanAreaSize, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + ), + ), + ), + ], + ), + ), + // 扫描框边角 + Positioned( + left: left, + top: top, + child: _buildCorner(true, true), + ), + Positioned( + right: left, + top: top, + child: _buildCorner(false, true), + ), + Positioned( + left: left, + bottom: constraints.maxHeight - top - scanAreaSize, + child: _buildCorner(true, false), + ), + Positioned( + right: left, + bottom: constraints.maxHeight - top - scanAreaSize, + child: _buildCorner(false, false), + ), + ], + ); + }, + ); + } + + /// 构建边角装饰 + Widget _buildCorner(bool isLeft, bool isTop) { + return SizedBox( + width: 24.w, + height: 24.w, + child: CustomPaint( + painter: _CornerPainter( + isLeft: isLeft, + isTop: isTop, + color: const Color(0xFFD4AF37), + strokeWidth: 3.w, + ), + ), + ); + } +} + +/// 边角绘制器 +class _CornerPainter extends CustomPainter { + final bool isLeft; + final bool isTop; + final Color color; + final double strokeWidth; + + _CornerPainter({ + required this.isLeft, + required this.isTop, + required this.color, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final path = Path(); + + if (isLeft && isTop) { + path.moveTo(0, size.height); + path.lineTo(0, 0); + path.lineTo(size.width, 0); + } else if (!isLeft && isTop) { + path.moveTo(0, 0); + path.lineTo(size.width, 0); + path.lineTo(size.width, size.height); + } else if (isLeft && !isTop) { + path.moveTo(0, 0); + path.lineTo(0, size.height); + path.lineTo(size.width, size.height); + } else { + path.moveTo(0, size.height); + path.lineTo(size.width, size.height); + path.lineTo(size.width, 0); + } + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +}