diff --git a/frontend/mobile-app/.claude/settings.local.json b/frontend/mobile-app/.claude/settings.local.json index c4f687dd..f7078760 100644 --- a/frontend/mobile-app/.claude/settings.local.json +++ b/frontend/mobile-app/.claude/settings.local.json @@ -6,7 +6,28 @@ "Bash(git add:*)", "Bash(flutter pub get:*)", "Bash(flutter analyze:*)", - "Bash(git commit -m \"$(cat <<''EOF''\nfeat: 添加APK在线升级和遥测统计模块\n\nAPK升级模块 (lib/core/updater/):\n- 支持自建服务器和Google Play双渠道更新\n- 版本检测、APK下载、SHA-256校验、安装\n- 应用市场来源检测\n- 强制更新和普通更新对话框\n\n遥测模块 (lib/core/telemetry/):\n- 设备信息采集 (品牌、型号、系统版本、屏幕等)\n- 会话管理 (DAU日活统计)\n- 心跳服务 (实时在线人数统计)\n- 事件队列和批量上传\n- 远程配置热更新\n\nAndroid原生配置:\n- MainActivity.kt Platform Channel实现\n- FileProvider配置 (APK安装)\n- 权限配置 (INTERNET, REQUEST_INSTALL_PACKAGES)\n\n文档:\n- docs/backend_api_guide.md 后端API开发指南\n- docs/testing_guide.md 测试指南\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude \nEOF\n)\")" + "Bash(git commit -m \"$(cat <<''EOF''\nfeat: 添加APK在线升级和遥测统计模块\n\nAPK升级模块 (lib/core/updater/):\n- 支持自建服务器和Google Play双渠道更新\n- 版本检测、APK下载、SHA-256校验、安装\n- 应用市场来源检测\n- 强制更新和普通更新对话框\n\n遥测模块 (lib/core/telemetry/):\n- 设备信息采集 (品牌、型号、系统版本、屏幕等)\n- 会话管理 (DAU日活统计)\n- 心跳服务 (实时在线人数统计)\n- 事件队列和批量上传\n- 远程配置热更新\n\nAndroid原生配置:\n- MainActivity.kt Platform Channel实现\n- FileProvider配置 (APK安装)\n- 权限配置 (INTERNET, REQUEST_INSTALL_PACKAGES)\n\n文档:\n- docs/backend_api_guide.md 后端API开发指南\n- docs/testing_guide.md 测试指南\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude \nEOF\n)\")", + "Bash(mkdir:*)", + "Bash(keytool:*)", + "Bash(flutter doctor:*)", + "Bash(flutter emulators:*)", + "Bash(c:Androidemulatoremulator -list-avds)", + "Bash(flutter devices:*)", + "Bash(c:Androidcmdline-toolslatestbinsdkmanager.bat --list)", + "Bash(findstr:*)", + "Bash(cmd /c \"c:\\Android\\cmdline-tools\\latest\\bin\\sdkmanager.bat --list\")", + "Bash(cmd /c \"dir c:\\Android\\cmdline-tools\\latest\\bin\")", + "Bash(cmd /c \"dir c:\\Android\")", + "Bash(dir:*)", + "Bash(powershell -Command:*)", + "Bash(Select-String \"system-images\")", + "Bash(Select-Object -First 10)", + "Bash(powershell:*)", + "Bash(c:/Android/platform-tools/adb.exe devices:*)", + "Bash(c:/Android/platform-tools/adb.exe kill-server)", + "Bash(c:/Android/platform-tools/adb.exe start-server:*)", + "Bash(flutter run:*)", + "Bash(git commit:*)" ], "deny": [], "ask": [] diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart index 7905a124..409fccbe 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart @@ -2,6 +2,7 @@ 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'; @@ -257,77 +258,155 @@ class _WelcomePageContentState extends State<_WelcomePageContent> { super.dispose(); } + /// 打开二维码扫描页面 + Future _openQrScanner() async { + final result = await Navigator.of(context).push( + 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 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), + 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), + ], ), ), ), ), - ), - SizedBox(height: 100.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: 62.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: 80.h), - ], + ); + }, ), ); } @@ -356,11 +435,17 @@ class _WelcomePageContentState extends State<_WelcomePageContent> { ), ), const Spacer(), - // 扫码图标 - Icon( - Icons.qr_code_scanner, - size: 24.sp, - color: const Color(0xFFA99F93), + // 扫码图标 - 点击打开扫描页面 + GestureDetector( + onTap: _openQrScanner, + child: Container( + padding: EdgeInsets.all(8.w), + child: Icon( + Icons.qr_code_scanner, + size: 24.sp, + color: const Color(0xFF8E794A), + ), + ), ), ], ), @@ -378,22 +463,40 @@ class _WelcomePageContentState extends State<_WelcomePageContent> { ), ), ), - child: TextField( - controller: _referralCodeController, - decoration: InputDecoration( - hintText: '请输入推荐码 / 序列号', - hintStyle: TextStyle( - fontSize: 16.sp, - color: const Color(0xFFA99F93), + 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), + ), + ), ), - 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), @@ -440,3 +543,239 @@ class _WelcomePageContentState extends State<_WelcomePageContent> { ); } } + +/// 二维码扫描页面 +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 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; + }); + } + + @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; +} diff --git a/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift index 97a72e3a..87cdbc2b 100644 --- a/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import device_info_plus import file_selector_macos import flutter_secure_storage_macos import local_auth_darwin +import mobile_scanner import package_info_plus import path_provider_foundation import share_plus @@ -23,6 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/frontend/mobile-app/pubspec.lock b/frontend/mobile-app/pubspec.lock index 18391069..e5fc6f89 100644 --- a/frontend/mobile-app/pubspec.lock +++ b/frontend/mobile-app/pubspec.lock @@ -909,6 +909,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" mocktail: dependency: "direct dev" description: diff --git a/frontend/mobile-app/pubspec.yaml b/frontend/mobile-app/pubspec.yaml index f712cb21..5dc67df8 100644 --- a/frontend/mobile-app/pubspec.yaml +++ b/frontend/mobile-app/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: shimmer: ^3.0.0 lottie: ^3.1.0 qr_flutter: ^4.1.0 + mobile_scanner: ^5.1.1 flutter_screenutil: ^5.9.0 city_pickers: ^1.3.0