feat(mobile): improve UX with QR scanning and Huawei compatibility fixes
- Disable Impeller rendering engine to fix video playback on Huawei devices - Update splash screen text from "榴莲女皇" to "榴莲皇后" and use app logo - Make referral code input scan-only (disable manual input) on guide page 5 - Add "import mnemonic" entry on guide page 5 for account recovery - Replace paste button with QR scanner in USDT withdraw address input - Add camera and gallery QR scanning support for wallet addresses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
62b6714760
commit
033268deb9
|
|
@ -36,6 +36,11 @@
|
|||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- 禁用 Impeller 渲染引擎以修复华为设备视频播放问题 -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
|
||||
<!-- FileProvider for APK installation (Android 7.0+) -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
|
|
|||
|
|
@ -328,6 +328,12 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
|||
return _isValidReferralCode(_referralCodeController.text);
|
||||
}
|
||||
|
||||
/// 导入助记词恢复账号
|
||||
void _importMnemonic() {
|
||||
debugPrint('[GuidePage] _importMnemonic - 跳转到导入助记词页面');
|
||||
Navigator.of(context).pushNamed(RoutePaths.importMnemonic);
|
||||
}
|
||||
|
||||
/// 保存推荐码并继续下一步
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,55 +196,14 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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<WithdrawUsdtPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 打开二维码扫描页面
|
||||
Future<void> _openQrScanner() async {
|
||||
final result = await Navigator.of(context).push<String>(
|
||||
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<WithdrawUsdtPage> {
|
|||
),
|
||||
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<WithdrawUsdtPage> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 地址二维码扫描页面
|
||||
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<Barcode> 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<void> _toggleTorch() async {
|
||||
await _controller?.toggleTorch();
|
||||
setState(() {
|
||||
_torchOn = !_torchOn;
|
||||
});
|
||||
}
|
||||
|
||||
/// 从相册选择图片并扫描二维码
|
||||
Future<void> _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<Color>(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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue