feat(genex-mobile): 邀请好友 — 分享二维码页面全链路实现

- 新增 SharePage:推荐码 QR 码 + 邀请进度 + 一键复制/原生分享
- ProfilePage 添加「邀请好友」渐变横幅入口
- 新增 ReferralService(getMyInfo / getDirectReferrals)
- pubspec.yaml 引入 qr_flutter ^4.1.0、share_plus ^10.0.2
- 路由 /share 注册
- i18n:4 语言新增 share.* 共 20 个翻译键(zh-CN / zh-TW / en / ja)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 01:28:22 -08:00
parent 3ae5e2f982
commit 46d2404d19
9 changed files with 668 additions and 0 deletions

View File

@ -783,6 +783,27 @@ const Map<String, String> en = {
'update.isLatest': 'Already up to date',
'update.checkUpdate': 'Check for Updates',
// ============ Share / Invite ============
'share.title': 'Invite Friends',
'share.scanToJoin': 'Scan to Download Genex',
'share.myReferralCode': 'My Referral Code',
'share.copyCode': 'Copy Code',
'share.copyLink': 'Copy Link',
'share.shareToFriend': 'Share with Friends',
'share.directReferrals': 'Direct Referrals',
'share.teamSize': 'Team Size',
'share.quickShare': 'Share Via',
'share.codeCopied': 'Referral Code Copied',
'share.linkCopied': 'Link Copied',
'share.shareText': 'Join me on Genex, the digital coupon finance platform!\nUse my referral code {code} when signing up for exclusive rewards.\nDownload here: {link}',
'share.nativeShareTitle': 'Share Genex',
'share.shareSubtitle': 'Share via WeChat, WhatsApp, or more',
'share.loading': 'Loading referral info...',
'share.loadFailed': 'Failed to load, please retry',
'share.retry': 'Retry',
'share.inviteBanner': 'Invite Friends',
'share.inviteBannerSub': 'Earn rewards for every referral',
// ============ Notification ============
'notification.system': 'System',
'notification.activity': 'Activity',

View File

@ -784,6 +784,27 @@ const Map<String, String> ja = {
'update.isLatest': '最新バージョンです',
'update.checkUpdate': 'アップデート確認',
// ============ Share / Invite ============
'share.title': '友達を招待',
'share.scanToJoin': 'スキャンしてGenexをダウンロード',
'share.myReferralCode': 'マイ招待コード',
'share.copyCode': 'コードをコピー',
'share.copyLink': 'リンクをコピー',
'share.shareToFriend': '友達にシェア',
'share.directReferrals': '直接招待',
'share.teamSize': 'チーム人数',
'share.quickShare': 'シェア方法',
'share.codeCopied': '招待コードをコピーしました',
'share.linkCopied': 'リンクをコピーしました',
'share.shareText': 'GenexでデジタルクーポンFinanceを楽しもう\n招待コード {code} で登録すると特典があります。\nダウンロード:{link}',
'share.nativeShareTitle': 'Genexをシェア',
'share.shareSubtitle': 'WeChat・WhatsApp等でシェア',
'share.loading': '招待情報を読み込み中...',
'share.loadFailed': '読み込みに失敗しました。再試行してください',
'share.retry': '再試行',
'share.inviteBanner': '友達を招待',
'share.inviteBannerSub': '友達を招待してポイントをゲット',
// ============ Notification ============
'notification.system': 'システム',
'notification.activity': 'アクティビティ',

View File

@ -784,6 +784,27 @@ const Map<String, String> zhCN = {
'update.isLatest': '当前已是最新版本',
'update.checkUpdate': '检查更新',
// ============ Share / Invite ============
'share.title': '邀请好友',
'share.scanToJoin': '扫码下载 Genex',
'share.myReferralCode': '我的专属推荐码',
'share.copyCode': '复制推荐码',
'share.copyLink': '复制链接',
'share.shareToFriend': '分享给好友',
'share.directReferrals': '直接推荐',
'share.teamSize': '团队人数',
'share.quickShare': '分享方式',
'share.codeCopied': '推荐码已复制',
'share.linkCopied': '链接已复制',
'share.shareText': '我在用 Genex 玩数字券金融!使用我的推荐码 {code} 注册,立享专属福利。\n下载链接:{link}',
'share.nativeShareTitle': '分享 Genex',
'share.shareSubtitle': '通过微信、WhatsApp 或其他方式分享',
'share.loading': '正在加载推荐信息...',
'share.loadFailed': '加载失败,请重试',
'share.retry': '重试',
'share.inviteBanner': '邀请好友',
'share.inviteBannerSub': '推荐好友注册,双方均享福利',
// ============ Notification ============
'notification.system': '系统通知',
'notification.activity': '活动通知',

View File

@ -784,6 +784,27 @@ const Map<String, String> zhTW = {
'update.isLatest': '當前已是最新版本',
'update.checkUpdate': '檢查更新',
// ============ Share / Invite ============
'share.title': '邀請好友',
'share.scanToJoin': '掃碼下載 Genex',
'share.myReferralCode': '我的專屬推薦碼',
'share.copyCode': '複製推薦碼',
'share.copyLink': '複製連結',
'share.shareToFriend': '分享給好友',
'share.directReferrals': '直接推薦',
'share.teamSize': '團隊人數',
'share.quickShare': '分享方式',
'share.codeCopied': '推薦碼已複製',
'share.linkCopied': '連結已複製',
'share.shareText': '我在用 Genex 玩數位券金融!使用我的推薦碼 {code} 註冊,立享專屬福利。\n下載連結:{link}',
'share.nativeShareTitle': '分享 Genex',
'share.shareSubtitle': '透過微信、WhatsApp 或其他方式分享',
'share.loading': '正在載入推薦資訊...',
'share.loadFailed': '載入失敗,請重試',
'share.retry': '重試',
'share.inviteBanner': '邀請好友',
'share.inviteBannerSub': '推薦好友註冊,雙方均享福利',
// ============ Notification ============
'notification.system': '系統通知',
'notification.activity': '活動通知',

View File

@ -0,0 +1,57 @@
import '../network/api_client.dart';
///
class ReferralInfo {
final String referralCode;
final int directReferralCount;
final int totalTeamCount;
final String? referrerId;
final String? usedCode;
ReferralInfo({
required this.referralCode,
required this.directReferralCount,
required this.totalTeamCount,
this.referrerId,
this.usedCode,
});
factory ReferralInfo.fromJson(Map<String, dynamic> json) {
return ReferralInfo(
referralCode: json['referralCode'] as String,
directReferralCount: json['directReferralCount'] as int? ?? 0,
totalTeamCount: json['totalTeamCount'] as int? ?? 0,
referrerId: json['referrerId'] as String?,
usedCode: json['usedCode'] as String?,
);
}
}
/// Referral Service referral-service API
class ReferralService {
static final ReferralService _instance = ReferralService._();
static ReferralService get instance => _instance;
ReferralService._();
final _api = ApiClient.instance;
///
Future<ReferralInfo> getMyInfo() async {
final resp = await _api.get('/api/v1/referral/my');
final data = resp.data['data'] as Map<String, dynamic>;
return ReferralInfo.fromJson(data);
}
///
Future<List<Map<String, dynamic>>> getDirectReferrals({
int offset = 0,
int limit = 20,
}) async {
final resp = await _api.get('/api/v1/referral/direct', queryParameters: {
'offset': offset,
'limit': limit,
});
final data = resp.data['data'] as Map<String, dynamic>;
return (data['items'] as List).cast<Map<String, dynamic>>();
}
}

View File

@ -23,6 +23,9 @@ class ProfilePage extends StatelessWidget {
// Quick Stats
SliverToBoxAdapter(child: _buildQuickStats(context)),
// Invite Banner
SliverToBoxAdapter(child: _buildInviteBanner(context)),
// Menu Sections
SliverToBoxAdapter(child: _buildMenuSection(context.t('profile.account'), [
_MenuItem(Icons.verified_user_outlined, context.t('profile.kyc'), '${context.t('kyc.completed')} L1', true,
@ -171,6 +174,67 @@ class ProfilePage extends StatelessWidget {
);
}
Widget _buildInviteBanner(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: GestureDetector(
onTap: () => Navigator.pushNamed(context, '/share'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFF6C5CE7), Color(0xFF9B8FFF)],
),
borderRadius: AppSpacing.borderRadiusMd,
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.25),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Colors.white24,
shape: BoxShape.circle,
),
child: const Icon(Icons.card_giftcard_rounded, color: Colors.white, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.t('share.inviteBanner'),
style: AppTypography.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
context.t('share.inviteBannerSub'),
style: AppTypography.caption.copyWith(color: Colors.white70),
),
],
),
),
const Icon(Icons.chevron_right_rounded, color: Colors.white70, size: 24),
],
),
),
),
);
}
Widget _buildMenuSection(String title, List<_MenuItem> items) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),

View File

@ -0,0 +1,458 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_typography.dart';
import '../../../../app/theme/app_spacing.dart';
import '../../../../core/services/referral_service.dart';
import '../../../../shared/widgets/genex_button.dart';
import '../../../../app/i18n/app_localizations.dart';
/// A9. / 广
///
///
/// - App
/// - /
/// - WhatsAppTelegram
/// - +
class SharePage extends StatefulWidget {
const SharePage({super.key});
@override
State<SharePage> createState() => _SharePageState();
}
class _SharePageState extends State<SharePage> {
ReferralInfo? _info;
bool _loading = true;
String? _error;
/// App
static const String _baseInviteUrl = 'https://app.gogenex.com/download';
String get _inviteLink => _info != null
? '$_baseInviteUrl?ref=${_info!.referralCode}'
: _baseInviteUrl;
@override
void initState() {
super.initState();
_loadInfo();
}
Future<void> _loadInfo() async {
setState(() {
_loading = true;
_error = null;
});
try {
final info = await ReferralService.instance.getMyInfo();
if (mounted) setState(() { _info = info; _loading = false; });
} catch (e) {
if (mounted) setState(() { _error = e.toString(); _loading = false; });
}
}
void _showCopied(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle_rounded, color: Colors.white, size: 18),
const SizedBox(width: 8),
Text(message),
],
),
backgroundColor: AppColors.primary,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
),
);
}
Future<void> _copyCode() async {
if (_info == null) return;
await Clipboard.setData(ClipboardData(text: _info!.referralCode));
if (mounted) _showCopied(context.t('share.codeCopied'));
}
Future<void> _copyLink() async {
await Clipboard.setData(ClipboardData(text: _inviteLink));
if (mounted) _showCopied(context.t('share.linkCopied'));
}
Future<void> _shareNative() async {
final code = _info?.referralCode ?? '';
final text = context
.t('share.shareText')
.replaceAll('{code}', code)
.replaceAll('{link}', _inviteLink);
await Share.share(text, subject: context.t('share.nativeShareTitle'));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(context.t('share.title')),
backgroundColor: Colors.transparent,
elevation: 0,
surfaceTintColor: Colors.transparent,
),
body: _loading
? _buildLoading()
: _error != null
? _buildError()
: _buildContent(),
);
}
Widget _buildLoading() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
context.t('share.loading'),
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary),
),
],
),
);
}
Widget _buildError() {
return Center(
child: Padding(
padding: AppSpacing.pagePadding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, size: 56, color: AppColors.textTertiary),
const SizedBox(height: 16),
Text(
context.t('share.loadFailed'),
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
TextButton.icon(
onPressed: _loadInfo,
icon: const Icon(Icons.refresh_rounded),
label: Text(context.t('share.retry')),
style: TextButton.styleFrom(foregroundColor: AppColors.primary),
),
],
),
),
);
}
Widget _buildContent() {
return SingleChildScrollView(
padding: AppSpacing.pagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeroCard(),
const SizedBox(height: 16),
_buildStatsCard(),
const SizedBox(height: 16),
_buildShareActions(),
const SizedBox(height: 24),
GenexButton(
label: context.t('share.shareToFriend'),
onPressed: _shareNative,
),
const SizedBox(height: 40),
],
),
);
}
// Hero Card: QR +
Widget _buildHeroCard() {
return Container(
decoration: BoxDecoration(
gradient: AppColors.cardGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.35),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: Column(
children: [
//
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.auto_awesome_rounded, color: Colors.white60, size: 16),
const SizedBox(width: 6),
Text(
context.t('share.scanToJoin'),
style: AppTypography.bodyMedium.copyWith(color: Colors.white70),
),
],
),
const SizedBox(height: 6),
Text(
'Genex',
style: AppTypography.displayMedium.copyWith(
color: Colors.white,
letterSpacing: 3,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 24),
// QR
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: QrImageView(
data: _inviteLink,
version: QrVersions.auto,
size: 180,
backgroundColor: Colors.white,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Color(0xFF4834D4),
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Color(0xFF6C5CE7),
),
),
),
const SizedBox(height: 24),
//
Text(
context.t('share.myReferralCode'),
style: AppTypography.caption.copyWith(color: Colors.white60),
),
const SizedBox(height: 8),
GestureDetector(
onTap: _copyCode,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(40),
border: Border.all(color: Colors.white30),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_info?.referralCode ?? '------',
style: AppTypography.h2.copyWith(
color: Colors.white,
letterSpacing: 4,
fontWeight: FontWeight.w700,
fontFamily: 'monospace',
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.copy_rounded, size: 12, color: Colors.white),
const SizedBox(width: 4),
Text(
context.t('share.copyCode'),
style: AppTypography.caption.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
),
],
),
),
);
}
//
Widget _buildStatsCard() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppSpacing.borderRadiusMd,
border: Border.all(color: AppColors.borderLight),
boxShadow: AppSpacing.shadowSm,
),
child: Row(
children: [
Expanded(
child: _buildStatItem(
icon: Icons.people_rounded,
value: '${_info?.directReferralCount ?? 0}',
label: context.t('share.directReferrals'),
),
),
Container(width: 1, height: 44, color: AppColors.borderLight),
Expanded(
child: _buildStatItem(
icon: Icons.groups_rounded,
value: '${_info?.totalTeamCount ?? 0}',
label: context.t('share.teamSize'),
),
),
],
),
);
}
Widget _buildStatItem({
required IconData icon,
required String value,
required String label,
}) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: AppColors.primary),
const SizedBox(width: 4),
Text(
value,
style: AppTypography.h2.copyWith(color: AppColors.primary),
),
],
),
const SizedBox(height: 2),
Text(
label,
style: AppTypography.caption.copyWith(color: AppColors.textSecondary),
),
],
);
}
//
Widget _buildShareActions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Text(
context.t('share.quickShare'),
style: AppTypography.labelSmall.copyWith(color: AppColors.textTertiary),
),
),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppSpacing.borderRadiusMd,
border: Border.all(color: AppColors.borderLight),
boxShadow: AppSpacing.shadowSm,
),
child: Column(
children: [
_buildActionTile(
icon: Icons.qr_code_rounded,
iconBg: AppColors.primaryContainer,
iconColor: AppColors.primary,
title: context.t('share.copyCode'),
subtitle: _info?.referralCode ?? '',
onTap: _copyCode,
),
const Divider(indent: 60, height: 1),
_buildActionTile(
icon: Icons.link_rounded,
iconBg: AppColors.infoLight,
iconColor: AppColors.info,
title: context.t('share.copyLink'),
subtitle: _inviteLink,
onTap: _copyLink,
),
const Divider(indent: 60, height: 1),
_buildActionTile(
icon: Icons.share_rounded,
iconBg: AppColors.successLight,
iconColor: AppColors.success,
title: context.t('share.shareToFriend'),
subtitle: context.t('share.shareSubtitle'),
onTap: _shareNative,
),
],
),
),
],
);
}
Widget _buildActionTile({
required IconData icon,
required Color iconBg,
required Color iconColor,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 38,
height: 38,
decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)),
child: Icon(icon, size: 20, color: iconColor),
),
title: Text(title, style: AppTypography.bodyMedium),
subtitle: Text(
subtitle,
style: AppTypography.caption.copyWith(color: AppColors.textTertiary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right_rounded, color: AppColors.textTertiary, size: 20),
onTap: onTap,
);
}
}

View File

@ -36,6 +36,7 @@ import 'features/issuer/presentation/pages/issuer_main_page.dart';
import 'features/merchant/presentation/pages/merchant_home_page.dart';
import 'features/trading/presentation/pages/trading_detail_page.dart';
import 'features/coupons/presentation/pages/wallet_coupons_page.dart';
import 'features/profile/presentation/pages/share_page.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -179,6 +180,8 @@ class _GenexConsumerAppState extends State<GenexConsumerApp> {
return MaterialPageRoute(builder: (_) => const TradingDetailPage());
case '/wallet/coupons':
return MaterialPageRoute(builder: (_) => const WalletCouponsPage());
case '/share':
return MaterialPageRoute(builder: (_) => const SharePage());
default:
return MaterialPageRoute(
builder: (_) => Scaffold(

View File

@ -23,6 +23,8 @@ dependencies:
flutter_local_notifications: ^18.0.0
in_app_update: ^4.2.2
shared_preferences: ^2.2.3
qr_flutter: ^4.1.0
share_plus: ^10.0.2
dev_dependencies:
flutter_test: