merge(invite): 客户端邀请奖励 UI 轨(Task 11-13)并入
invite api/provider + invite页真实化(邀请区+战绩+TG任务卡,六语) + 注册页邀请码输入。 客户端并行轨,文件与后端不相交,SDD 逐任务评审全过。
This commit is contained in:
@@ -603,6 +603,15 @@ abstract class AppText {
|
|||||||
String get inviteRewardFirstBuy; // 好友首购 · 双方各得 15 天 / First purchase · +15 days each
|
String get inviteRewardFirstBuy; // 好友首购 · 双方各得 15 天 / First purchase · +15 days each
|
||||||
String get inviteRewardJoinTg; // 加入 TG 频道 · 得 3 天 / Join TG · +3 days
|
String get inviteRewardJoinTg; // 加入 TG 频道 · 得 3 天 / Join TG · +3 days
|
||||||
String get inviteProgressTitle; // 我的奖励 / My rewards
|
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 ③占位) ──
|
// ── 通知(Spec ③占位) ──
|
||||||
String get notifTitle; // 通知 / Notifications
|
String get notifTitle; // 通知 / Notifications
|
||||||
String get notifEmpty; // 暂无通知 / No notifications
|
String get notifEmpty; // 暂无通知 / No notifications
|
||||||
|
|||||||
@@ -494,6 +494,24 @@ class StringsEn extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get inviteProgressTitle => 'My rewards';
|
String get inviteProgressTitle => 'My rewards';
|
||||||
@override
|
@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';
|
String get notifTitle => 'Notifications';
|
||||||
@override
|
@override
|
||||||
String get notifEmpty => 'No notifications';
|
String get notifEmpty => 'No notifications';
|
||||||
|
|||||||
@@ -495,6 +495,24 @@ class StringsEs extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get inviteProgressTitle => 'Mis recompensas';
|
String get inviteProgressTitle => 'Mis recompensas';
|
||||||
@override
|
@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';
|
String get notifTitle => 'Notificaciones';
|
||||||
@override
|
@override
|
||||||
String get notifEmpty => 'Aún no hay notificaciones';
|
String get notifEmpty => 'Aún no hay notificaciones';
|
||||||
|
|||||||
@@ -495,6 +495,24 @@ class StringsJa extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get inviteProgressTitle => '獲得した特典';
|
String get inviteProgressTitle => '獲得した特典';
|
||||||
@override
|
@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 => '通知';
|
String get notifTitle => '通知';
|
||||||
@override
|
@override
|
||||||
String get notifEmpty => '通知はありません';
|
String get notifEmpty => '通知はありません';
|
||||||
|
|||||||
@@ -495,6 +495,24 @@ class StringsKo extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get inviteProgressTitle => '내 보상';
|
String get inviteProgressTitle => '내 보상';
|
||||||
@override
|
@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 => '알림';
|
String get notifTitle => '알림';
|
||||||
@override
|
@override
|
||||||
String get notifEmpty => '알림이 없습니다';
|
String get notifEmpty => '알림이 없습니다';
|
||||||
|
|||||||
@@ -495,6 +495,24 @@ class StringsRu extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get inviteProgressTitle => 'Мои награды';
|
String get inviteProgressTitle => 'Мои награды';
|
||||||
@override
|
@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 => 'Уведомления';
|
String get notifTitle => 'Уведомления';
|
||||||
@override
|
@override
|
||||||
String get notifEmpty => 'Пока нет уведомлений';
|
String get notifEmpty => 'Пока нет уведомлений';
|
||||||
|
|||||||
@@ -488,6 +488,24 @@ class StringsZh extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get inviteProgressTitle => '我的奖励';
|
String get inviteProgressTitle => '我的奖励';
|
||||||
@override
|
@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 => '通知';
|
String get notifTitle => '通知';
|
||||||
@override
|
@override
|
||||||
String get notifEmpty => '暂无通知';
|
String get notifEmpty => '暂无通知';
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
// invite_page.dart — 邀请好友(占位屏)
|
// invite_page.dart — 邀请好友:邀请区(码/链接)+ 战绩 + TG 任务卡。
|
||||||
//
|
//
|
||||||
// TODO(spec-2): 邀请码/链接由后端签发,奖励进度来自 /v1/invite。当前码/链接/奖励进度
|
// 数据来自 inviteProvider(Task 11,`/v1/invite`);未登录/加载/出错回退占位文案,
|
||||||
// 均为静态占位,仅供 IA 走查——接后端前不接任何真实业务逻辑。
|
// 不接任何静态 demo 数据。
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../l10n/app_text.dart';
|
import '../l10n/app_text.dart';
|
||||||
import '../pangolin_theme.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';
|
import '../widgets/pangolin_icons.dart';
|
||||||
|
import '../widgets/pangolin_toast.dart';
|
||||||
const String _kDemoInviteCode = 'PANGOLIN-DEMO';
|
|
||||||
const String _kDemoInviteLink = 'https://pangolin.app/i/PANGOLIN-DEMO';
|
|
||||||
|
|
||||||
class InviteScreen extends ConsumerWidget {
|
class InviteScreen extends ConsumerWidget {
|
||||||
const InviteScreen({super.key, required this.t, this.onBack, this.embedded = false});
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final c = context.pangolin;
|
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),
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||||
children: [
|
children: [
|
||||||
_CopyCard(
|
_CopyCard(
|
||||||
icon: PangolinIcons.gift,
|
icon: PangolinIcons.gift,
|
||||||
label: t.inviteCodeLabel,
|
label: t.inviteCodeLabel,
|
||||||
value: _kDemoInviteCode,
|
value: info.code,
|
||||||
mono: true,
|
mono: true,
|
||||||
copyLabel: t.inviteCopyCode,
|
copyLabel: t.inviteCopyCode,
|
||||||
onCopy: () => _copy(context, _kDemoInviteCode, t.copied),
|
onCopy: () => onCopy(context, info.code, t.copied),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_CopyCard(
|
_CopyCard(
|
||||||
icon: PangolinIcons.link,
|
icon: PangolinIcons.link,
|
||||||
label: t.inviteLinkLabel,
|
label: t.inviteLinkLabel,
|
||||||
value: _kDemoInviteLink,
|
value: info.link,
|
||||||
mono: false,
|
mono: false,
|
||||||
copyLabel: t.inviteCopyLink,
|
copyLabel: t.inviteCopyLink,
|
||||||
onCopy: () => _copy(context, _kDemoInviteLink, t.copied),
|
onCopy: () => onCopy(context, info.link, t.copied),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -82,44 +169,120 @@ class InviteScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: c.surface,
|
color: c.surface,
|
||||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||||
border: Border.all(color: c.border),
|
border: Border.all(color: c.border),
|
||||||
boxShadow: PangolinShadow.sm,
|
boxShadow: PangolinShadow.sm,
|
||||||
),
|
),
|
||||||
child: Column(children: [
|
child: Row(children: [
|
||||||
Icon(PangolinIcons.gift, size: 26, color: c.fg3),
|
_StatCell(value: '${info.invited}', label: t.inviteStatInvited),
|
||||||
const SizedBox(height: 10),
|
_StatDivider(color: c.border),
|
||||||
Text('—', style: PangolinText.body.copyWith(color: c.fg3)),
|
_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;
|
class _StatCell extends StatelessWidget {
|
||||||
return Scaffold(
|
const _StatCell({required this.value, required this.label});
|
||||||
backgroundColor: c.bg,
|
final String value;
|
||||||
body: SafeArea(
|
final String label;
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
@override
|
||||||
Padding(
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
|
final c = context.pangolin;
|
||||||
child: Row(children: [
|
return Expanded(
|
||||||
IconButton(
|
child: Padding(
|
||||||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
|
child: Column(children: [
|
||||||
),
|
Text(value, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||||
Text(t.inviteTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
const SizedBox(height: 4),
|
||||||
]),
|
Text(label, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||||
),
|
|
||||||
Expanded(child: body),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _CopyCard extends StatelessWidget {
|
||||||
const _CopyCard({
|
const _CopyCard({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
|||||||
@@ -115,12 +115,14 @@ class AuthApi {
|
|||||||
required String code,
|
required String code,
|
||||||
required String password,
|
required String password,
|
||||||
Map<String, dynamic>? device,
|
Map<String, dynamic>? device,
|
||||||
|
String? inviteCode,
|
||||||
}) async {
|
}) async {
|
||||||
final resp = await _post('/v1/auth/register', {
|
final resp = await _post('/v1/auth/register', {
|
||||||
'email': email,
|
'email': email,
|
||||||
'code': code,
|
'code': code,
|
||||||
'password': password,
|
'password': password,
|
||||||
if (device != null) 'device': device,
|
if (device != null) 'device': device,
|
||||||
|
if (inviteCode != null && inviteCode.isNotEmpty) 'invite_code': inviteCode,
|
||||||
});
|
});
|
||||||
if (resp.statusCode != 200 && resp.statusCode != 201) {
|
if (resp.statusCode != 200 && resp.statusCode != 201) {
|
||||||
_throwFromResponse(resp);
|
_throwFromResponse(resp);
|
||||||
|
|||||||
@@ -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<String, dynamic> j) {
|
||||||
|
final tg = (j['telegram'] as Map<String, dynamic>?) ?? 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<InviteInfo> fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite'));
|
||||||
|
Future<String> telegramStartLink() async =>
|
||||||
|
(await _c.getJson('/v1/tasks/telegram/start'))['deep_link'] as String? ?? '';
|
||||||
|
}
|
||||||
@@ -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<InviteApi>((ref) => InviteApi(ref.watch(apiClientProvider)));
|
||||||
|
|
||||||
|
class InviteNotifier extends AsyncNotifier<InviteInfo?> {
|
||||||
|
@override
|
||||||
|
Future<InviteInfo?> build() async {
|
||||||
|
// 未登录返回 null(不打网络);已登录拉取。
|
||||||
|
final token = ref.watch(authProvider).accessToken;
|
||||||
|
if (token == null || token.isEmpty) return null;
|
||||||
|
return ref.read(inviteApiProvider).fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final inviteProvider = AsyncNotifierProvider<InviteNotifier, InviteInfo?>(InviteNotifier.new);
|
||||||
@@ -24,10 +24,14 @@ const _easeOut = Cubic(0.22, 1, 0.36, 1);
|
|||||||
enum _AuthMode { login, register }
|
enum _AuthMode { login, register }
|
||||||
|
|
||||||
class AuthScreen extends ConsumerStatefulWidget {
|
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 VoidCallback onDone;
|
||||||
final AppText t;
|
final AppText t;
|
||||||
|
|
||||||
|
/// 测试可注入(见 test/widget/auth_invite_field_test.dart);生产不传,内部
|
||||||
|
/// 用 kApiBaseUrl 现建一个,行为不变。
|
||||||
|
final AuthApi? api;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<AuthScreen> createState() => _AuthScreenState();
|
ConsumerState<AuthScreen> createState() => _AuthScreenState();
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
final _email = TextEditingController();
|
final _email = TextEditingController();
|
||||||
final _code = TextEditingController();
|
final _code = TextEditingController();
|
||||||
final _pw = TextEditingController();
|
final _pw = TextEditingController();
|
||||||
|
final _invite = TextEditingController();
|
||||||
|
|
||||||
final _emailFocus = FocusNode();
|
final _emailFocus = FocusNode();
|
||||||
final _pwFocus = FocusNode();
|
final _pwFocus = FocusNode();
|
||||||
@@ -54,7 +59,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
duration: const Duration(milliseconds: 620),
|
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);
|
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||||
|
|
||||||
@@ -79,6 +84,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
_email.dispose();
|
_email.dispose();
|
||||||
_code.dispose();
|
_code.dispose();
|
||||||
_pw.dispose();
|
_pw.dispose();
|
||||||
|
_invite.dispose();
|
||||||
_emailFocus.dispose();
|
_emailFocus.dispose();
|
||||||
_pwFocus.dispose();
|
_pwFocus.dispose();
|
||||||
_codeFocus.dispose();
|
_codeFocus.dispose();
|
||||||
@@ -108,6 +114,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
code: _code.text.trim(),
|
code: _code.text.trim(),
|
||||||
password: _pw.text,
|
password: _pw.text,
|
||||||
device: device,
|
device: device,
|
||||||
|
inviteCode: _invite.text.trim(),
|
||||||
);
|
);
|
||||||
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
|
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
|
||||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||||
@@ -464,6 +471,11 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)),
|
_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),
|
const SizedBox(height: 18),
|
||||||
_primaryBtn(c, label: _loading ? '...' : t.doCreate,
|
_primaryBtn(c, label: _loading ? '...' : t.doCreate,
|
||||||
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null),
|
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> saveTokens({required String access, required String refresh}) async {}
|
||||||
|
@override
|
||||||
|
Future<String?> loadAccessToken() async => null;
|
||||||
|
@override
|
||||||
|
Future<String?> loadRefreshToken() async => null;
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {}
|
||||||
|
@override
|
||||||
|
Future<void> markOnboarded() async {}
|
||||||
|
@override
|
||||||
|
Future<bool> isOnboarded() async => true;
|
||||||
|
@override
|
||||||
|
Future<void> saveLastEmail(String email) async {}
|
||||||
|
@override
|
||||||
|
Future<String?> loadLastEmail() async => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(disableGoogleFontsFetching);
|
||||||
|
const t = StringsZh();
|
||||||
|
|
||||||
|
Future<void> _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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user