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:
hailin 2025-12-11 05:39:58 -08:00
parent 62b6714760
commit 033268deb9
4 changed files with 509 additions and 68 deletions

View File

@ -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"

View File

@ -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,
),
),
],
),
),
),
],
);
}

View File

@ -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',

View File

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