From cdf67a200cb5066eeb73efd43a0357d7e5cc97df Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 07:39:01 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(client):=20invite=20api=20+=20provider?= =?UTF-8?q?=20+=20auth=5Fapi.register=20=E5=8A=A0=20inviteCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/lib/services/auth_api.dart | 2 ++ client/lib/services/invite_api.dart | 35 +++++++++++++++++++++++++++ client/lib/state/invite_provider.dart | 25 +++++++++++++++++++ client/test/unit/invite_api_test.dart | 24 ++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 client/lib/services/invite_api.dart create mode 100644 client/lib/state/invite_provider.dart create mode 100644 client/test/unit/invite_api_test.dart diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart index b5d9466..2ebb9e0 100644 --- a/client/lib/services/auth_api.dart +++ b/client/lib/services/auth_api.dart @@ -115,12 +115,14 @@ class AuthApi { required String code, required String password, Map? device, + String? inviteCode, }) async { final resp = await _post('/v1/auth/register', { 'email': email, 'code': code, 'password': password, if (device != null) 'device': device, + if (inviteCode != null && inviteCode.isNotEmpty) 'invite_code': inviteCode, }); if (resp.statusCode != 200 && resp.statusCode != 201) { _throwFromResponse(resp); diff --git a/client/lib/services/invite_api.dart b/client/lib/services/invite_api.dart new file mode 100644 index 0000000..af13de5 --- /dev/null +++ b/client/lib/services/invite_api.dart @@ -0,0 +1,35 @@ +// invite_api.dart — 邀请/奖励任务代理端点封装(JWT 经 ApiClient 自动注入)。 +import 'api_client.dart'; + +class InviteInfo { + const InviteInfo({ + required this.code, required this.link, required this.invited, + required this.converted, required this.earnedDays, + required this.tgEnabled, required this.tgJoined, required this.channel, + }); + final String code, link, channel; + final int invited, converted, earnedDays; + final bool tgEnabled, tgJoined; + + factory InviteInfo.fromJson(Map j) { + final tg = (j['telegram'] as Map?) ?? const {}; + return InviteInfo( + code: j['invite_code'] as String? ?? '', + link: j['invite_link'] as String? ?? '', + invited: (j['invited'] as num?)?.toInt() ?? 0, + converted: (j['converted'] as num?)?.toInt() ?? 0, + earnedDays: (j['earned_days'] as num?)?.toInt() ?? 0, + tgEnabled: tg['enabled'] as bool? ?? false, + tgJoined: tg['joined'] as bool? ?? false, + channel: tg['channel'] as String? ?? '', + ); + } +} + +class InviteApi { + InviteApi(this._c); + final ApiClient _c; + Future fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite')); + Future telegramStartLink() async => + (await _c.getJson('/v1/tasks/telegram/start'))['deep_link'] as String? ?? ''; +} diff --git a/client/lib/state/invite_provider.dart b/client/lib/state/invite_provider.dart new file mode 100644 index 0000000..acd3acc --- /dev/null +++ b/client/lib/state/invite_provider.dart @@ -0,0 +1,25 @@ +// invite_provider.dart — 邀请信息状态装配:复用 apiClientProvider,未登录不打网络。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/invite_api.dart'; +import 'account_providers.dart'; +import 'auth_provider.dart'; + +final inviteApiProvider = Provider((ref) => InviteApi(ref.watch(apiClientProvider))); + +class InviteNotifier extends AsyncNotifier { + @override + Future build() async { + // 未登录返回 null(不打网络);已登录拉取。 + final token = ref.watch(authProvider).accessToken; + if (token == null || token.isEmpty) return null; + return ref.read(inviteApiProvider).fetch(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch()); + } +} + +final inviteProvider = AsyncNotifierProvider(InviteNotifier.new); diff --git a/client/test/unit/invite_api_test.dart b/client/test/unit/invite_api_test.dart new file mode 100644 index 0000000..da1431a --- /dev/null +++ b/client/test/unit/invite_api_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/invite_api.dart'; + +ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m); + +void main() { + test('fetch 解析邀请信息 + TG 任务态', () async { + final api = InviteApi(_c(MockClient((req) async { + expect(req.url.path, '/v1/invite'); + return http.Response('{"invite_code":"ABCD2345","invite_link":"https://x/i/ABCD2345",' + '"invited":3,"converted":1,"earned_days":16,' + '"telegram":{"enabled":true,"joined":false,"channel":"@pangolin_app"}}', 200); + }))); + final info = await api.fetch(); + expect(info.code, 'ABCD2345'); + expect(info.invited, 3); + expect(info.earnedDays, 16); + expect(info.tgEnabled, true); + expect(info.tgJoined, false); + }); +} From e061237496d41638dc78648f8bd88fc502c05fd8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 07:51:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(client/invite):=20=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E9=A1=B5=E7=9C=9F=E5=AE=9E=E5=8C=96(=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E5=8C=BA+=E6=88=98=E7=BB=A9+TG=20=E4=BB=BB=E5=8A=A1=E5=8D=A1)+?= =?UTF-8?q?=20=E5=85=AD=E8=AF=AD=20l10n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/lib/l10n/app_text.dart | 9 + client/lib/l10n/strings_en.dart | 18 ++ client/lib/l10n/strings_es.dart | 18 ++ client/lib/l10n/strings_ja.dart | 18 ++ client/lib/l10n/strings_ko.dart | 18 ++ client/lib/l10n/strings_ru.dart | 18 ++ client/lib/l10n/strings_zh.dart | 18 ++ client/lib/screens/invite_page.dart | 227 +++++++++++++++++++---- client/test/widget/invite_page_test.dart | 31 ++++ 9 files changed, 343 insertions(+), 32 deletions(-) create mode 100644 client/test/widget/invite_page_test.dart 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; +} From 0b24e26fb61e8095b90fed6bd01fc3d39010c468 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 08:00:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(client/auth):=20=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=A1=B5=E5=8A=A0=E9=82=80=E8=AF=B7=E7=A0=81(=E9=80=89?= =?UTF-8?q?=E5=A1=AB)=E8=BE=93=E5=85=A5,=E6=8F=90=E4=BA=A4=E6=97=B6?= =?UTF-8?q?=E4=BC=A0=20invite=5Fcode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/lib/widgets/auth_screen.dart | 16 ++- .../test/widget/auth_invite_field_test.dart | 103 ++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 client/test/widget/auth_invite_field_test.dart diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index e3b4baf..14fd0b4 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -24,10 +24,14 @@ const _easeOut = Cubic(0.22, 1, 0.36, 1); enum _AuthMode { login, register } class AuthScreen extends ConsumerStatefulWidget { - const AuthScreen({super.key, required this.onDone, required this.t}); + const AuthScreen({super.key, required this.onDone, required this.t, this.api}); final VoidCallback onDone; final AppText t; + /// 测试可注入(见 test/widget/auth_invite_field_test.dart);生产不传,内部 + /// 用 kApiBaseUrl 现建一个,行为不变。 + final AuthApi? api; + @override ConsumerState createState() => _AuthScreenState(); } @@ -44,6 +48,7 @@ class _AuthScreenState extends ConsumerState final _email = TextEditingController(); final _code = TextEditingController(); final _pw = TextEditingController(); + final _invite = TextEditingController(); final _emailFocus = FocusNode(); final _pwFocus = FocusNode(); @@ -54,7 +59,7 @@ class _AuthScreenState extends ConsumerState duration: const Duration(milliseconds: 620), ); - late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl); + late final AuthApi _api = widget.api ?? AuthApi(baseUrl: kApiBaseUrl); bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text); @@ -79,6 +84,7 @@ class _AuthScreenState extends ConsumerState _email.dispose(); _code.dispose(); _pw.dispose(); + _invite.dispose(); _emailFocus.dispose(); _pwFocus.dispose(); _codeFocus.dispose(); @@ -108,6 +114,7 @@ class _AuthScreenState extends ConsumerState code: _code.text.trim(), password: _pw.text, device: device, + inviteCode: _invite.text.trim(), ); await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim()); await ref.read(authProvider.notifier).saveTokens(tokens); @@ -464,6 +471,11 @@ class _AuthScreenState extends ConsumerState ), const SizedBox(height: 16), _field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)), + const SizedBox(height: 16), + _field(c, icon: PangolinIcons.gift, label: t.inviteCodeFieldLabel, focused: false, + child: TextField(key: const Key('invite-code-field'), controller: _invite, + textInputAction: TextInputAction.done, + decoration: _bare.copyWith(hintText: ''))), const SizedBox(height: 18), _primaryBtn(c, label: _loading ? '...' : t.doCreate, onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null), diff --git a/client/test/widget/auth_invite_field_test.dart b/client/test/widget/auth_invite_field_test.dart new file mode 100644 index 0000000..f3db199 --- /dev/null +++ b/client/test/widget/auth_invite_field_test.dart @@ -0,0 +1,103 @@ +// auth_invite_field_test.dart — Task 13: 注册页「邀请码(选填)」输入框 +// +// 驱动路径:切到注册 tab(step0)→ 填邮箱 → 点发送验证码(mock 网络 204)→ +// 填 6 位验证码 → 点下一步(进 step1 设密码步)→ 断言 step1 渲染出 +// key 为 invite-code-field 的 TextField,label 为 t.inviteCodeFieldLabel。 +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/widgets/auth_screen.dart'; + +import '../helpers/harness.dart'; + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + setUpAll(disableGoogleFontsFetching); + const t = StringsZh(); + + Future _driveToRegisterStep1(WidgetTester tester) async { + final mockClient = MockClient((req) async { + if (req.method == 'POST' && req.url.path == '/v1/auth/code') { + return http.Response('', 204); + } + return http.Response(jsonEncode({'message_zh': 'unexpected'}), 404); + }); + final api = AuthApi(baseUrl: 'http://test.invalid', client: mockClient); + + await tester.pumpWidget(ProviderScope( + overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: AuthScreen(t: t, onDone: () {}, api: api), + ), + )); + await tester.pumpAndSettle(); + + // 切到注册 tab。 + await tester.tap(find.text(t.tabRegister)); + await tester.pumpAndSettle(); + + // step0:填邮箱 → 点发送验证码(mock 204)。 + await tester.enterText(find.byType(TextField).first, 'invitee@example.com'); + await tester.pump(); + await tester.tap(find.text(t.sendCode)); + await tester.pumpAndSettle(); + + // 填 6 位验证码 → 下一步,进 step1。 + await tester.enterText(find.byType(TextField).at(1), '123456'); + await tester.pump(); + await tester.tap(find.text(t.doNext)); + await tester.pumpAndSettle(); + } + + testWidgets('注册 step1(设密码步)渲染邀请码(选填)输入框', (tester) async { + await _driveToRegisterStep1(tester); + + expect(find.byKey(const Key('invite-code-field')), findsOneWidget); + expect(find.text(t.inviteCodeFieldLabel), findsOneWidget); + }); + + testWidgets('登录路径不含邀请码输入框', (tester) async { + final api = AuthApi(baseUrl: 'http://test.invalid', client: MockClient((req) async => http.Response('', 404))); + await tester.pumpWidget(ProviderScope( + overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: AuthScreen(t: t, onDone: () {}, api: api), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('invite-code-field')), findsNothing); + }); +}