rwadurian/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart

782 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:mobile_scanner/mobile_scanner.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 {
const GuidePage({super.key});
@override
ConsumerState<GuidePage> createState() => _GuidePageState();
}
class _GuidePageState extends ConsumerState<GuidePage> {
final PageController _pageController = PageController();
int _currentPage = 0;
// 向导页1-4的数据
final List<GuidePageData> _guidePages = const [
GuidePageData(
imagePath: 'assets/images/guide_1.png',
title: '认种一棵榴莲树\n拥有真实RWA资产',
subtitle: '绑定真实果园20年收益让区块链与农业完美结合',
),
GuidePageData(
imagePath: 'assets/images/guide_2.png',
title: '认种即可开启算力\n自动挖矿持续收益',
subtitle: '每一棵树都对应真实资产注入,为算力提供真实价值支撑',
),
GuidePageData(
imagePath: 'assets/images/guide_3.png',
title: '分享链接\n获得团队算力与收益',
subtitle: '真实认种数据透明可信 · 团队越大算力越强',
),
GuidePageData(
imagePath: 'assets/images/guide_4.png',
title: 'MPC多方安全\n所有地址与收益可审计',
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);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: 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)
Widget _buildGuidePage(GuidePageData data, int index) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Column(
children: [
SizedBox(height: 64.h),
// 图片区域
Expanded(
flex: 5,
child: Container(
width: 312.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
color: const Color(0xFFFFF8E7),
),
child: data.imagePath != null
? ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: Image.asset(
data.imagePath!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildPlaceholderImage(index);
},
),
)
: _buildPlaceholderImage(index),
),
),
SizedBox(height: 48.h),
// 标题
Text(
data.title,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w700,
height: 1.33,
color: const Color(0xFF292524),
),
textAlign: TextAlign.center,
),
SizedBox(height: 16.h),
// 副标题
Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Text(
data.subtitle,
style: TextStyle(
fontSize: 14.sp,
height: 1.43,
color: const Color(0xFF57534E),
),
textAlign: TextAlign.center,
),
),
SizedBox(height: 48.h),
// 页面指示器
_buildPageIndicator(),
SizedBox(height: 80.h),
],
),
);
}
/// 构建占位图片
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,
);
}
/// 构建页面指示器
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
? const Color(0xFF8E794A)
: const Color(0xFFEAE0CD),
),
);
}),
);
}
}
/// 欢迎加入页面内容 (第5页)
class _WelcomePageContent extends StatefulWidget {
final VoidCallback onNext;
const _WelcomePageContent({required this.onNext});
@override
State<_WelcomePageContent> createState() => _WelcomePageContentState();
}
class _WelcomePageContentState extends State<_WelcomePageContent> {
bool _hasReferrer = true;
final TextEditingController _referralCodeController = TextEditingController();
@override
void dispose() {
_referralCodeController.dispose();
super.dispose();
}
/// 打开二维码扫描页面
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;
});
}
}
/// 从扫描结果中提取推荐码
/// 支持以下格式:
/// - 完整 URL: 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);
// 尝试从路径中提取 (如 /r/ABC123 或 /ABC123)
final pathSegments = uri.pathSegments;
if (pathSegments.isNotEmpty) {
// 取最后一个路径段作为推荐码
final lastSegment = pathSegments.last;
if (lastSegment.isNotEmpty) {
return lastSegment;
}
// 如果最后一段是 'r',取倒数第二段
if (pathSegments.length >= 2 && lastSegment == 'r') {
return pathSegments[pathSegments.length - 2];
}
}
// 尝试从查询参数中提取 (如 ?ref=ABC123 或 ?code=ABC123)
final refCode = uri.queryParameters['ref'] ??
uri.queryParameters['code'] ??
uri.queryParameters['referral'];
if (refCode != null && refCode.isNotEmpty) {
return refCode;
}
} catch (e) {
// URL 解析失败,返回原始数据
debugPrint('URL 解析失败: $e');
}
}
// 不是 URL 或解析失败,返回原始数据
return data;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
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: 32.h),
child: GestureDetector(
onTap: () {
// 退出向导
Navigator.of(context).maybePop();
},
child: Text(
'退出 Exit',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFFA99F93),
),
),
),
),
),
SizedBox(height: 80.h),
// 欢迎标题
Text(
'欢迎加入',
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w700,
height: 1.33,
color: const Color(0xFF6F6354),
),
),
SizedBox(height: 12.h),
// 副标题
Text(
'创建账号前的最后一步 · 请选择是否有推荐人',
style: TextStyle(
fontSize: 14.sp,
height: 1.43,
color: const Color(0xFFA99F93),
),
),
SizedBox(height: 48.h),
// 选项区域
_buildReferrerOptions(),
const Spacer(),
// 下一步按钮
GestureDetector(
onTap: widget.onNext,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 16.h),
child: Text(
'下一步 (创建账号)',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
height: 1.5,
color: const Color(0xFFD9C8A9),
),
textAlign: TextAlign.right,
),
),
),
SizedBox(height: 48.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: const Color(0xFF6F6354),
),
),
const Spacer(),
// 扫码图标 - 点击打开扫描页面
GestureDetector(
onTap: _openQrScanner,
child: Container(
padding: EdgeInsets.all(8.w),
child: Icon(
Icons.qr_code_scanner,
size: 24.sp,
color: const Color(0xFF8E794A),
),
),
),
],
),
),
SizedBox(height: 14.h),
// 推荐码输入框
if (_hasReferrer)
Container(
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 4.w),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1,
color: Color(0xFFEAE1D2),
),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _referralCodeController,
decoration: InputDecoration(
hintText: '请输入推荐码 / 序列号',
hintStyle: TextStyle(
fontSize: 16.sp,
color: const Color(0xFFA99F93),
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF6F6354),
),
),
),
// 扫码按钮
GestureDetector(
onTap: _openQrScanner,
child: Padding(
padding: EdgeInsets.only(left: 8.w),
child: Icon(
Icons.camera_alt_outlined,
size: 20.sp,
color: const Color(0xFFA99F93),
),
),
),
],
),
),
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: const Color(0xFF6F6354),
),
),
],
),
),
],
);
}
/// 构建单选按钮
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
? const Color(0xFF2563EB)
: const Color(0xFFA99F93),
),
),
);
}
}
/// 二维码扫描页面
class _QrScannerPage extends StatefulWidget {
const _QrScannerPage();
@override
State<_QrScannerPage> createState() => _QrScannerPageState();
}
class _QrScannerPageState extends State<_QrScannerPage> {
MobileScannerController? _controller;
bool _hasScanned = false;
bool _torchOn = 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;
});
}
@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: 100.h,
left: 0,
right: 0,
child: Text(
'将二维码放入框内,即可自动扫描',
style: TextStyle(
fontSize: 14.sp,
color: Colors.white70,
),
textAlign: TextAlign.center,
),
),
],
),
);
}
/// 构建扫描框遮罩
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;
}