1147 lines
36 KiB
Dart
1147 lines
36 KiB
Dart
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 '../../../../core/storage/storage_keys.dart';
|
||
import '../../../../routes/route_paths.dart';
|
||
import '../providers/auth_provider.dart';
|
||
|
||
/// 向导页数据模型
|
||
class GuidePageData {
|
||
final String? imagePath;
|
||
final String title;
|
||
final String subtitle;
|
||
final Widget? customContent;
|
||
|
||
const GuidePageData({
|
||
this.imagePath,
|
||
required this.title,
|
||
required this.subtitle,
|
||
this.customContent,
|
||
});
|
||
}
|
||
|
||
/// 向导页面 - 用户首次打开应用时展示
|
||
/// 支持左右滑动切换页面
|
||
class GuidePage extends ConsumerStatefulWidget {
|
||
/// 初始页面索引(0-4,默认为0)
|
||
final int initialPage;
|
||
|
||
const GuidePage({super.key, this.initialPage = 0});
|
||
|
||
@override
|
||
ConsumerState<GuidePage> createState() => _GuidePageState();
|
||
}
|
||
|
||
class _GuidePageState extends ConsumerState<GuidePage> {
|
||
late final PageController _pageController;
|
||
late int _currentPage;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 使用传入的初始页面索引
|
||
_currentPage = widget.initialPage.clamp(0, 4);
|
||
_pageController = PageController(initialPage: _currentPage);
|
||
// 延迟到 build 后获取屏幕信息
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_logScreenInfo();
|
||
});
|
||
}
|
||
|
||
/// 打印屏幕信息用于调试
|
||
void _logScreenInfo() {
|
||
final mediaQuery = MediaQuery.of(context);
|
||
final screenSize = mediaQuery.size;
|
||
final devicePixelRatio = mediaQuery.devicePixelRatio;
|
||
final physicalSize = screenSize * devicePixelRatio;
|
||
|
||
debugPrint('[GuidePage] ========== 屏幕信息 ==========');
|
||
debugPrint('[GuidePage] 逻辑分辨率: ${screenSize.width.toStringAsFixed(1)} x ${screenSize.height.toStringAsFixed(1)}');
|
||
debugPrint('[GuidePage] 设备像素比: $devicePixelRatio');
|
||
debugPrint('[GuidePage] 物理分辨率: ${physicalSize.width.toStringAsFixed(0)} x ${physicalSize.height.toStringAsFixed(0)}');
|
||
debugPrint('[GuidePage] 屏幕宽高比: ${(screenSize.width / screenSize.height).toStringAsFixed(3)} (${screenSize.width.toStringAsFixed(0)}:${screenSize.height.toStringAsFixed(0)})');
|
||
debugPrint('[GuidePage] 图片设计比例: 0.5625 (1080:1920 = 9:16)');
|
||
debugPrint('[GuidePage] ================================');
|
||
}
|
||
|
||
// 向导页1-5的数据 (第5页为欢迎加入页)
|
||
// 支持 png、jpg、webp 等格式
|
||
final List<GuidePageData> _guidePages = const [
|
||
GuidePageData(
|
||
imagePath: 'assets/images/guide_1.jpg',
|
||
title: '认种一棵榴莲树\n拥有真实RWA资产',
|
||
subtitle: '绑定真实果园20年收益,让区块链与农业完美结合',
|
||
),
|
||
GuidePageData(
|
||
imagePath: 'assets/images/guide_2.jpg',
|
||
title: '认种即可开启算力\n自动挖矿持续收益',
|
||
subtitle: '每一棵树都对应真实资产注入,为算力提供真实价值支撑',
|
||
),
|
||
GuidePageData(
|
||
imagePath: 'assets/images/guide_3.jpg',
|
||
title: '分享链接\n获得团队算力与收益',
|
||
subtitle: '真实认种数据透明可信 · 团队越大算力越强',
|
||
),
|
||
GuidePageData(
|
||
imagePath: 'assets/images/guide_4.jpg',
|
||
title: 'MPC多方安全\n所有地址与收益可审计',
|
||
subtitle: '你的资产 · 安全透明 · 不可被篡改',
|
||
),
|
||
GuidePageData(
|
||
imagePath: 'assets/images/guide_5.jpg',
|
||
title: '欢迎加入',
|
||
subtitle: '创建账号前的最后一步 · 请选择是否有推荐人',
|
||
),
|
||
];
|
||
|
||
@override
|
||
void dispose() {
|
||
_pageController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onPageChanged(int page) {
|
||
setState(() {
|
||
_currentPage = page;
|
||
});
|
||
}
|
||
|
||
void _goToNextPage() {
|
||
if (_currentPage < 4) {
|
||
_pageController.nextPage(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
}
|
||
|
||
void _goToOnboarding() async {
|
||
// 标记已查看向导页
|
||
await ref.read(authProvider.notifier).markGuideAsSeen();
|
||
if (!mounted) return;
|
||
context.go(RoutePaths.onboarding);
|
||
}
|
||
|
||
/// 退出应用
|
||
void _exitApp() {
|
||
SystemNavigator.pop();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.white,
|
||
body: PageView.builder(
|
||
controller: _pageController,
|
||
onPageChanged: _onPageChanged,
|
||
itemCount: 5, // 4个介绍页 + 1个欢迎加入页
|
||
itemBuilder: (context, index) {
|
||
if (index < 4) {
|
||
return _buildGuidePage(_guidePages[index], index);
|
||
} else {
|
||
return _buildWelcomePage();
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建向导页 (页面1-4) - 全屏背景图片,无文字
|
||
/// 使用 BoxFit.cover 填满屏幕,保持宽高比(会裁剪超出部分)
|
||
Widget _buildGuidePage(GuidePageData data, int index) {
|
||
debugPrint('[GuidePage] _buildGuidePage() - 页面 ${index + 1}, 图片: ${data.imagePath}');
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
// 全屏背景图片 - 使用 cover 填满屏幕
|
||
if (data.imagePath != null)
|
||
Image.asset(
|
||
data.imagePath!,
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
height: double.infinity,
|
||
errorBuilder: (context, error, stackTrace) {
|
||
debugPrint('[GuidePage] 页面 ${index + 1} 图片加载失败: $error');
|
||
return Container(
|
||
color: const Color(0xFFFFF8E7),
|
||
child: _buildPlaceholderImage(index),
|
||
);
|
||
},
|
||
)
|
||
else
|
||
Container(
|
||
color: const Color(0xFFFFF8E7),
|
||
child: _buildPlaceholderImage(index),
|
||
),
|
||
// 底部页面指示器 - 使用 SafeArea 避免被系统 UI 遮挡
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: SafeArea(
|
||
child: Padding(
|
||
padding: EdgeInsets.only(bottom: 40.h),
|
||
child: _buildPageIndicator(),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 构建占位图片
|
||
Widget _buildPlaceholderImage(int index) {
|
||
final icons = [
|
||
Icons.nature,
|
||
Icons.memory,
|
||
Icons.people,
|
||
Icons.security,
|
||
];
|
||
final colors = [
|
||
const Color(0xFF8BC34A),
|
||
const Color(0xFFD4AF37),
|
||
const Color(0xFFFF9800),
|
||
const Color(0xFF2196F3),
|
||
];
|
||
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
icons[index],
|
||
size: 80.sp,
|
||
color: colors[index],
|
||
),
|
||
SizedBox(height: 16.h),
|
||
Text(
|
||
'向导页 ${index + 1}',
|
||
style: TextStyle(
|
||
fontSize: 16.sp,
|
||
color: const Color(0xFF57534E),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建欢迎加入页面 (页面5)
|
||
Widget _buildWelcomePage() {
|
||
return _WelcomePageContent(
|
||
onNext: _goToOnboarding,
|
||
onExit: _exitApp, // 退出整个应用
|
||
backgroundImage: _guidePages[4].imagePath,
|
||
pageIndicator: _buildPageIndicator(),
|
||
);
|
||
}
|
||
|
||
/// 构建页面指示器 (所有页面都使用白色系,因为都有背景图)
|
||
Widget _buildPageIndicator() {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: List.generate(5, (index) {
|
||
final isActive = index == _currentPage;
|
||
return Container(
|
||
width: 8.w,
|
||
height: 8.w,
|
||
margin: EdgeInsets.symmetric(horizontal: 4.w),
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: isActive
|
||
? Colors.white
|
||
: Colors.white.withValues(alpha: 0.4),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 欢迎加入页面内容 (第5页)
|
||
class _WelcomePageContent extends ConsumerStatefulWidget {
|
||
final VoidCallback onNext;
|
||
final VoidCallback onExit;
|
||
final String? backgroundImage;
|
||
final Widget pageIndicator;
|
||
|
||
const _WelcomePageContent({
|
||
required this.onNext,
|
||
required this.onExit,
|
||
this.backgroundImage,
|
||
required this.pageIndicator,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<_WelcomePageContent> createState() => _WelcomePageContentState();
|
||
}
|
||
|
||
class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
||
bool _hasReferrer = true;
|
||
final TextEditingController _referralCodeController = TextEditingController();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 监听输入框变化以更新按钮状态
|
||
_referralCodeController.addListener(_onReferralCodeChanged);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_referralCodeController.removeListener(_onReferralCodeChanged);
|
||
_referralCodeController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 输入框内容变化时刷新UI
|
||
void _onReferralCodeChanged() {
|
||
setState(() {});
|
||
}
|
||
|
||
/// 验证推荐码是否有效
|
||
/// 支持: 纯推荐码(至少3个字符) 或 合法URL
|
||
bool _isValidReferralCode(String code) {
|
||
final trimmed = code.trim();
|
||
if (trimmed.isEmpty) return false;
|
||
|
||
// 如果是URL格式,检查是否能解析
|
||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||
try {
|
||
final uri = Uri.parse(trimmed);
|
||
// URL必须有路径或查询参数
|
||
return uri.pathSegments.isNotEmpty || uri.queryParameters.isNotEmpty;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 纯推荐码至少3个字符
|
||
return trimmed.length >= 3;
|
||
}
|
||
|
||
/// 判断按钮是否可点击
|
||
bool get _canProceed {
|
||
// 如果选择"没有推荐人",按钮可点击
|
||
if (!_hasReferrer) return true;
|
||
|
||
// 如果选择"有推荐人",需要填写有效的推荐码
|
||
return _isValidReferralCode(_referralCodeController.text);
|
||
}
|
||
|
||
/// 导入助记词恢复账号
|
||
void _importMnemonic() {
|
||
debugPrint('[GuidePage] _importMnemonic - 跳转到导入助记词页面');
|
||
context.push(RoutePaths.importMnemonic);
|
||
}
|
||
|
||
/// 保存推荐码并继续下一步
|
||
Future<void> _saveReferralCodeAndProceed() async {
|
||
if (!_canProceed) return;
|
||
|
||
// 如果有推荐人且推荐码有效,保存到本地存储
|
||
if (_hasReferrer && _referralCodeController.text.trim().isNotEmpty) {
|
||
final secureStorage = ref.read(secureStorageProvider);
|
||
await secureStorage.write(
|
||
key: StorageKeys.inviterReferralCode,
|
||
value: _referralCodeController.text.trim(),
|
||
);
|
||
debugPrint('[GuidePage] 保存邀请人推荐码: ${_referralCodeController.text.trim()}');
|
||
}
|
||
|
||
// 调用下一步回调
|
||
widget.onNext();
|
||
}
|
||
|
||
/// 打开二维码扫描页面
|
||
Future<void> _openQrScanner() async {
|
||
final result = await Navigator.of(context).push<String>(
|
||
MaterialPageRoute(
|
||
builder: (context) => const _QrScannerPage(),
|
||
),
|
||
);
|
||
if (result != null && result.isNotEmpty) {
|
||
// 从扫描结果中提取推荐码
|
||
final referralCode = _extractReferralCode(result);
|
||
setState(() {
|
||
_referralCodeController.text = referralCode;
|
||
_hasReferrer = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 从扫描结果中提取推荐码
|
||
/// 支持以下格式:
|
||
/// - 查询参数: https://app.rwadurian.com/share?ref=ABC123 -> ABC123
|
||
/// - 路径格式: https://app.rwadurian.com/r/ABC123 -> ABC123
|
||
/// - 短链: https://rwa.link/ABC123 -> ABC123
|
||
/// - 纯推荐码: ABC123 -> ABC123
|
||
String _extractReferralCode(String scannedData) {
|
||
// 去除首尾空格
|
||
String data = scannedData.trim();
|
||
|
||
// 如果是 URL,尝试提取推荐码
|
||
if (data.startsWith('http://') || data.startsWith('https://')) {
|
||
try {
|
||
final uri = Uri.parse(data);
|
||
|
||
// 优先从查询参数中提取 (如 ?ref=ABC123 或 ?code=ABC123)
|
||
final refCode = uri.queryParameters['ref'] ??
|
||
uri.queryParameters['code'] ??
|
||
uri.queryParameters['referral'];
|
||
if (refCode != null && refCode.isNotEmpty) {
|
||
return refCode;
|
||
}
|
||
|
||
// 其次从路径中提取 (如 /r/ABC123 或 /ABC123)
|
||
final pathSegments = uri.pathSegments;
|
||
if (pathSegments.isNotEmpty) {
|
||
// 取最后一个路径段作为推荐码
|
||
final lastSegment = pathSegments.last;
|
||
// 排除常见的非推荐码路径段
|
||
const excludedSegments = ['share', 'invite', 'r', 'ref', 'referral'];
|
||
if (lastSegment.isNotEmpty && !excludedSegments.contains(lastSegment.toLowerCase())) {
|
||
return lastSegment;
|
||
}
|
||
// 如果最后一段是排除项且有倒数第二段,检查倒数第二段
|
||
if (pathSegments.length >= 2) {
|
||
final secondLastSegment = pathSegments[pathSegments.length - 2];
|
||
if (secondLastSegment.isNotEmpty && !excludedSegments.contains(secondLastSegment.toLowerCase())) {
|
||
return secondLastSegment;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// URL 解析失败,返回原始数据
|
||
debugPrint('URL 解析失败: $e');
|
||
}
|
||
}
|
||
|
||
// 不是 URL 或解析失败,返回原始数据
|
||
return data;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: () => FocusScope.of(context).unfocus(),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
// 全屏背景图片 - 使用 cover 填满屏幕
|
||
if (widget.backgroundImage != null)
|
||
Image.asset(
|
||
widget.backgroundImage!,
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
height: double.infinity,
|
||
errorBuilder: (context, error, stackTrace) {
|
||
return Container(
|
||
color: const Color(0xFFFFF8E7),
|
||
);
|
||
},
|
||
)
|
||
else
|
||
Container(
|
||
color: const Color(0xFFFFF8E7),
|
||
),
|
||
// 半透明遮罩,让内容更清晰
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
Colors.black.withValues(alpha: 0.3),
|
||
Colors.black.withValues(alpha: 0.6),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
// 内容区域
|
||
SafeArea(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
return SingleChildScrollView(
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(
|
||
minHeight: constraints.maxHeight,
|
||
),
|
||
child: IntrinsicHeight(
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||
child: Column(
|
||
children: [
|
||
// 退出按钮
|
||
Align(
|
||
alignment: Alignment.topRight,
|
||
child: Padding(
|
||
padding: EdgeInsets.only(top: 16.h),
|
||
child: GestureDetector(
|
||
onTap: widget.onExit,
|
||
child: Text(
|
||
'退出',
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
color: Colors.white.withValues(alpha: 0.8),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 60.h),
|
||
// 欢迎标题
|
||
Text(
|
||
'欢迎加入',
|
||
style: TextStyle(
|
||
fontSize: 28.sp,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.33,
|
||
color: Colors.white,
|
||
shadows: [
|
||
Shadow(
|
||
color: Colors.black.withValues(alpha: 0.5),
|
||
blurRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(height: 12.h),
|
||
// 副标题
|
||
Text(
|
||
'创建账号前的最后一步 · 请选择是否有推荐人',
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
height: 1.43,
|
||
color: Colors.white.withValues(alpha: 0.9),
|
||
shadows: [
|
||
Shadow(
|
||
color: Colors.black.withValues(alpha: 0.5),
|
||
blurRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(height: 48.h),
|
||
// 选项区域 (透明背景)
|
||
_buildReferrerOptions(),
|
||
const Spacer(),
|
||
// 页面指示器
|
||
widget.pageIndicator,
|
||
SizedBox(height: 24.h),
|
||
// 下一步按钮
|
||
GestureDetector(
|
||
onTap: _canProceed ? _saveReferralCodeAndProceed : null,
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||
decoration: BoxDecoration(
|
||
color: _canProceed
|
||
? const Color(0xFFD4A84B)
|
||
: Colors.white.withValues(alpha: 0.3),
|
||
borderRadius: BorderRadius.circular(12.r),
|
||
),
|
||
child: Text(
|
||
'下一步 (创建账号)',
|
||
style: TextStyle(
|
||
fontSize: 16.sp,
|
||
fontWeight: FontWeight.w600,
|
||
height: 1.5,
|
||
color: _canProceed
|
||
? Colors.white
|
||
: Colors.white.withValues(alpha: 0.6),
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 32.h),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建推荐人选项 (透明背景,白色文字)
|
||
Widget _buildReferrerOptions() {
|
||
return Column(
|
||
children: [
|
||
// 有推荐人选项
|
||
GestureDetector(
|
||
onTap: () {
|
||
setState(() {
|
||
_hasReferrer = true;
|
||
});
|
||
},
|
||
child: Row(
|
||
children: [
|
||
_buildRadio(_hasReferrer),
|
||
SizedBox(width: 12.w),
|
||
Text(
|
||
'我有推荐人',
|
||
style: TextStyle(
|
||
fontSize: 16.sp,
|
||
height: 1.5,
|
||
color: Colors.white,
|
||
shadows: [
|
||
Shadow(
|
||
color: Colors.black.withValues(alpha: 0.5),
|
||
blurRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(height: 14.h),
|
||
// 推荐码输入框 - 使用 AnimatedSize 平滑过渡
|
||
AnimatedSize(
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeInOut,
|
||
child: _hasReferrer
|
||
? Container(
|
||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 4.w),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
width: 1,
|
||
color: Colors.white.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// 扫码按钮
|
||
GestureDetector(
|
||
onTap: _openQrScanner,
|
||
child: Padding(
|
||
padding: EdgeInsets.only(left: 8.w),
|
||
child: Icon(
|
||
Icons.camera_alt_outlined,
|
||
size: 20.sp,
|
||
color: Colors.white.withValues(alpha: 0.8),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
SizedBox(height: 24.h),
|
||
// 没有推荐人选项
|
||
GestureDetector(
|
||
onTap: () {
|
||
setState(() {
|
||
_hasReferrer = false;
|
||
});
|
||
},
|
||
child: Row(
|
||
children: [
|
||
_buildRadio(!_hasReferrer),
|
||
SizedBox(width: 12.w),
|
||
Text(
|
||
'我没有推荐人',
|
||
style: TextStyle(
|
||
fontSize: 16.sp,
|
||
height: 1.5,
|
||
color: Colors.white,
|
||
shadows: [
|
||
Shadow(
|
||
color: Colors.black.withValues(alpha: 0.5),
|
||
blurRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 构建单选按钮 (白色系,适配深色背景)
|
||
Widget _buildRadio(bool isSelected) {
|
||
return Container(
|
||
width: 20.w,
|
||
height: 20.w,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
width: isSelected ? 6.w : 2.w,
|
||
color: isSelected
|
||
? Colors.white
|
||
: Colors.white.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 二维码扫描页面
|
||
class _QrScannerPage extends StatefulWidget {
|
||
const _QrScannerPage();
|
||
|
||
@override
|
||
State<_QrScannerPage> createState() => _QrScannerPageState();
|
||
}
|
||
|
||
class _QrScannerPageState extends State<_QrScannerPage> {
|
||
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(0xFFD4A84B),
|
||
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;
|
||
}
|