diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index a174cad..e90bbe2 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -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 diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index bb7405f..bff9460 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -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'; diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index 0e14f22..a45bb9d 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -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'; diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index d480006..0d44c77 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -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 => '通知はありません'; diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 41bb946..3063289 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -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 => '알림이 없습니다'; diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index 95b6c97..9752908 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -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 => 'Пока нет уведомлений'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 331283d..446d833 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -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 => '暂无通知'; diff --git a/client/lib/screens/invite_page.dart b/client/lib/screens/invite_page.dart index 8225385..02d8255 100644 --- a/client/lib/screens/invite_page.dart +++ b/client/lib/screens/invite_page.dart @@ -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 _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 Function(BuildContext, String, String) onCopy; + final Future 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 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, diff --git a/client/test/widget/invite_page_test.dart b/client/test/widget/invite_page_test.dart new file mode 100644 index 0000000..cac73b0 --- /dev/null +++ b/client/test/widget/invite_page_test.dart @@ -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 build() async => _v; +}