feat(client/invite): 邀请页真实化(邀请区+战绩+TG 任务卡)+ 六语 l10n

This commit is contained in:
wangjia
2026-07-13 07:51:19 +08:00
parent cdf67a200c
commit e061237496
9 changed files with 343 additions and 32 deletions
+9
View File
@@ -603,6 +603,15 @@ abstract class AppText {
String get inviteRewardFirstBuy; // 好友首购 · 双方各得 15 天 / First purchase · +15 days each
String get inviteRewardJoinTg; // 加入 TG 频道 · 得 3 天 / Join TG · +3 days
String get inviteProgressTitle; // 我的奖励 / My rewards
String get inviteTasksTitle; // 更多得会员 / More ways to earn
String get inviteJoinTgTitle; // 加入 Telegram 频道 +3 天 / Join Telegram channel
String get inviteJoinTgBtn; // 加入频道 / Join channel
String get inviteVerifyBtn; // 验证领取 / Verify & claim
String get inviteTaskDone; // 已领 +3 天 / Claimed +3 days
String get inviteStatInvited; // 已邀请 / Invited
String get inviteStatConverted; // 已转化 / Converted
String get inviteStatDays; // 累计获赠(天) / Days earned
String get inviteCodeFieldLabel; // 邀请码(选填) / Invite code (optional)
// ── 通知(Spec ③占位) ──
String get notifTitle; // 通知 / Notifications
String get notifEmpty; // 暂无通知 / No notifications
+18
View File
@@ -494,6 +494,24 @@ class StringsEn extends AppText {
@override
String get inviteProgressTitle => 'My rewards';
@override
String get inviteTasksTitle => 'More ways to earn';
@override
String get inviteJoinTgTitle => 'Join Telegram channel';
@override
String get inviteJoinTgBtn => 'Join channel';
@override
String get inviteVerifyBtn => 'Verify & claim';
@override
String get inviteTaskDone => 'Claimed +3 days';
@override
String get inviteStatInvited => 'Invited';
@override
String get inviteStatConverted => 'Converted';
@override
String get inviteStatDays => 'Days earned';
@override
String get inviteCodeFieldLabel => 'Invite code (optional)';
@override
String get notifTitle => 'Notifications';
@override
String get notifEmpty => 'No notifications';
+18
View File
@@ -495,6 +495,24 @@ class StringsEs extends AppText {
@override
String get inviteProgressTitle => 'Mis recompensas';
@override
String get inviteTasksTitle => 'Más formas de ganar';
@override
String get inviteJoinTgTitle => 'Unirse al canal de Telegram';
@override
String get inviteJoinTgBtn => 'Unirse al canal';
@override
String get inviteVerifyBtn => 'Verificar y reclamar';
@override
String get inviteTaskDone => 'Reclamado +3 días';
@override
String get inviteStatInvited => 'Invitados';
@override
String get inviteStatConverted => 'Convertidos';
@override
String get inviteStatDays => 'Días acumulados';
@override
String get inviteCodeFieldLabel => 'Código de invitación (opcional)';
@override
String get notifTitle => 'Notificaciones';
@override
String get notifEmpty => 'Aún no hay notificaciones';
+18
View File
@@ -495,6 +495,24 @@ class StringsJa extends AppText {
@override
String get inviteProgressTitle => '獲得した特典';
@override
String get inviteTasksTitle => 'もっと会員特典を得る';
@override
String get inviteJoinTgTitle => 'Telegramチャンネルに参加';
@override
String get inviteJoinTgBtn => 'チャンネルに参加';
@override
String get inviteVerifyBtn => '確認して受け取る';
@override
String get inviteTaskDone => '受け取り済み +3日';
@override
String get inviteStatInvited => '招待した人数';
@override
String get inviteStatConverted => '転換した人数';
@override
String get inviteStatDays => '累計獲得日数';
@override
String get inviteCodeFieldLabel => '招待コード(任意)';
@override
String get notifTitle => '通知';
@override
String get notifEmpty => '通知はありません';
+18
View File
@@ -495,6 +495,24 @@ class StringsKo extends AppText {
@override
String get inviteProgressTitle => '내 보상';
@override
String get inviteTasksTitle => '더 많은 혜택 받기';
@override
String get inviteJoinTgTitle => 'Telegram 채널 가입';
@override
String get inviteJoinTgBtn => '채널 가입';
@override
String get inviteVerifyBtn => '확인하고 받기';
@override
String get inviteTaskDone => '수령 완료 +3일';
@override
String get inviteStatInvited => '초대 수';
@override
String get inviteStatConverted => '전환 수';
@override
String get inviteStatDays => '누적 획득(일)';
@override
String get inviteCodeFieldLabel => '초대 코드(선택)';
@override
String get notifTitle => '알림';
@override
String get notifEmpty => '알림이 없습니다';
+18
View File
@@ -495,6 +495,24 @@ class StringsRu extends AppText {
@override
String get inviteProgressTitle => 'Мои награды';
@override
String get inviteTasksTitle => 'Больше способов заработать';
@override
String get inviteJoinTgTitle => 'Вступить в Telegram-канал';
@override
String get inviteJoinTgBtn => 'Вступить в канал';
@override
String get inviteVerifyBtn => 'Проверить и получить';
@override
String get inviteTaskDone => 'Получено +3 дня';
@override
String get inviteStatInvited => 'Приглашено';
@override
String get inviteStatConverted => 'Конвертировано';
@override
String get inviteStatDays => 'Всего получено (дней)';
@override
String get inviteCodeFieldLabel => 'Код приглашения (необязательно)';
@override
String get notifTitle => 'Уведомления';
@override
String get notifEmpty => 'Пока нет уведомлений';
+18
View File
@@ -488,6 +488,24 @@ class StringsZh extends AppText {
@override
String get inviteProgressTitle => '我的奖励';
@override
String get inviteTasksTitle => '更多得会员';
@override
String get inviteJoinTgTitle => '加入 Telegram 频道';
@override
String get inviteJoinTgBtn => '加入频道';
@override
String get inviteVerifyBtn => '验证领取';
@override
String get inviteTaskDone => '已领 +3 天';
@override
String get inviteStatInvited => '已邀请';
@override
String get inviteStatConverted => '已转化';
@override
String get inviteStatDays => '累计获赠(天)';
@override
String get inviteCodeFieldLabel => '邀请码(选填)';
@override
String get notifTitle => '通知';
@override
String get notifEmpty => '暂无通知';
+195 -32
View File
@@ -1,17 +1,20 @@
// invite_page.dart — 邀请好友(占位屏)
// invite_page.dart — 邀请好友:邀请区(码/链接)+ 战绩 + TG 任务卡。
//
// TODO(spec-2): 邀请码/链接由后端签发,奖励进度来自 /v1/invite。当前码/链接/奖励进度
// 均为静态占位,仅供 IA 走查——接后端前不接任何真实业务逻辑
// 数据来自 inviteProvider(Task 11,`/v1/invite`);未登录/加载/出错回退占位文案,
// 不接任何静态 demo 数据
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/channel_launch.dart';
import '../services/invite_api.dart';
import '../state/invite_provider.dart';
import '../widgets/pangolin_button.dart';
import '../widgets/pangolin_icons.dart';
const String _kDemoInviteCode = 'PANGOLIN-DEMO';
const String _kDemoInviteLink = 'https://pangolin.app/i/PANGOLIN-DEMO';
import '../widgets/pangolin_toast.dart';
class InviteScreen extends ConsumerWidget {
const InviteScreen({super.key, required this.t, this.onBack, this.embedded = false});
@@ -30,29 +33,113 @@ class InviteScreen extends ConsumerWidget {
}
}
// 验证领取:拉 deep_link 后外部打开。canLaunchUrl + try-catch 双保险,
// 失败给可见 toast(与 payment_page._openRedirectUrl 同法)。
Future<void> _verifyAndClaim(BuildContext context, WidgetRef ref) async {
try {
final link = await ref.read(inviteApiProvider).telegramStartLink();
final uri = link.isEmpty ? null : Uri.tryParse(link);
if (uri == null) {
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
return;
}
final can = await canLaunchUrl(uri);
if (!can) {
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
return;
}
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!ok && context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
if (ok) await ref.read(inviteProvider.notifier).refresh();
} catch (_) {
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final inviteAsync = ref.watch(inviteProvider);
Widget body = ListView(
Widget body = inviteAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _Placeholder(t: t),
data: (info) => info == null
? _Placeholder(t: t)
: _InviteContent(t: t, info: info, onCopy: _copy, onVerifyAndClaim: (ctx) => _verifyAndClaim(ctx, ref)),
);
if (embedded) return body;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(t.inviteTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
]),
),
);
}
}
/// loading/error/未登录 的统一回退占位(保留原 IA 走查态)。
class _Placeholder extends StatelessWidget {
const _Placeholder({required this.t});
final AppText t;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(PangolinIcons.gift, size: 26, color: c.fg3),
const SizedBox(height: 10),
Text('', style: PangolinText.body.copyWith(color: c.fg3)),
]),
),
);
}
}
class _InviteContent extends StatelessWidget {
const _InviteContent({required this.t, required this.info, required this.onCopy, required this.onVerifyAndClaim});
final AppText t;
final InviteInfo info;
final Future<void> Function(BuildContext, String, String) onCopy;
final Future<void> Function(BuildContext) onVerifyAndClaim;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
children: [
_CopyCard(
icon: PangolinIcons.gift,
label: t.inviteCodeLabel,
value: _kDemoInviteCode,
value: info.code,
mono: true,
copyLabel: t.inviteCopyCode,
onCopy: () => _copy(context, _kDemoInviteCode, t.copied),
onCopy: () => onCopy(context, info.code, t.copied),
),
const SizedBox(height: 14),
_CopyCard(
icon: PangolinIcons.link,
label: t.inviteLinkLabel,
value: _kDemoInviteLink,
value: info.link,
mono: false,
copyLabel: t.inviteCopyLink,
onCopy: () => _copy(context, _kDemoInviteLink, t.copied),
onCopy: () => onCopy(context, info.link, t.copied),
),
const SizedBox(height: 20),
Padding(
@@ -82,44 +169,120 @@ class InviteScreen extends ConsumerWidget {
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.lg),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Column(children: [
Icon(PangolinIcons.gift, size: 26, color: c.fg3),
const SizedBox(height: 10),
Text('', style: PangolinText.body.copyWith(color: c.fg3)),
child: Row(children: [
_StatCell(value: '${info.invited}', label: t.inviteStatInvited),
_StatDivider(color: c.border),
_StatCell(value: '${info.converted}', label: t.inviteStatConverted),
_StatDivider(color: c.border),
_StatCell(value: '${info.earnedDays}', label: t.inviteStatDays),
]),
),
if (info.tgEnabled) ...[
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.fromLTRB(2, 0, 2, 10),
child: Text(t.inviteTasksTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)),
),
_TgTaskCard(t: t, info: info, onVerifyAndClaim: onVerifyAndClaim),
],
],
);
}
}
if (embedded) return body;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(t.inviteTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
class _StatCell extends StatelessWidget {
const _StatCell({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Column(children: [
Text(value, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(label, style: PangolinText.caption.copyWith(color: c.fg3)),
]),
),
);
}
}
class _StatDivider extends StatelessWidget {
const _StatDivider({required this.color});
final Color color;
@override
Widget build(BuildContext context) => SizedBox(height: 48, child: VerticalDivider(width: 1, color: color));
}
class _TgTaskCard extends StatelessWidget {
const _TgTaskCard({required this.t, required this.info, required this.onVerifyAndClaim});
final AppText t;
final InviteInfo info;
final Future<void> Function(BuildContext) onVerifyAndClaim;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(PangolinRadius.xl),
border: Border.all(color: c.border),
boxShadow: PangolinShadow.sm,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)),
child: Icon(PangolinIcons.send, size: 17, color: c.accent),
),
const SizedBox(width: 10),
Expanded(
child: Text(t.inviteJoinTgTitle,
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 13.5)),
),
]),
const SizedBox(height: 14),
if (info.tgJoined)
PangolinButton(
label: t.inviteTaskDone,
icon: PangolinIcons.checkCircle,
variant: PangolinButtonVariant.secondary,
expand: true,
onPressed: null,
)
else
Column(children: [
PangolinButton(
label: t.inviteJoinTgBtn,
variant: PangolinButtonVariant.secondary,
expand: true,
onPressed: () => launchChannel(info.channel),
),
const SizedBox(height: 10),
PangolinButton(
label: t.inviteVerifyBtn,
variant: PangolinButtonVariant.primary,
expand: true,
onPressed: () => onVerifyAndClaim(context),
),
]),
]),
);
}
}
class _CopyCard extends StatelessWidget {
const _CopyCard({
required this.icon,
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/l10n/strings_en.dart';
import 'package:pangolin_vpn/screens/invite_page.dart';
import 'package:pangolin_vpn/services/invite_api.dart';
import 'package:pangolin_vpn/state/invite_provider.dart';
import '../helpers/harness.dart';
void main() {
testWidgets('邀请页显示真实码 + 战绩 + TG 任务卡', (tester) async {
const info = InviteInfo(code: 'ABCD2345', link: 'https://x/i/ABCD2345',
invited: 3, converted: 1, earnedDays: 16, tgEnabled: true, tgJoined: false, channel: '@pangolin_app');
await tester.binding.setSurfaceSize(const Size(400, 1200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(wrapThemed(
InviteScreen(t: StringsEn()),
overrides: [inviteProvider.overrideWith(() => _FakeNotifier(info))],
));
await tester.pumpAndSettle();
expect(find.text('ABCD2345'), findsOneWidget);
expect(find.text('Join channel'), findsOneWidget); // TG 任务卡
});
}
class _FakeNotifier extends InviteNotifier {
_FakeNotifier(this._v);
final InviteInfo _v;
@override
Future<InviteInfo?> build() async => _v;
}