// invite_page.dart — 邀请好友:邀请区(码/链接)+ 战绩 + TG 任务卡。 // // 数据来自 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/page_body.dart'; import '../widgets/pangolin_button.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/pangolin_toast.dart'; import '../widgets/sub_scaffold.dart'; class InviteScreen extends ConsumerWidget { const InviteScreen({super.key, required this.t, this.onBack, this.embedded = false}); final AppText t; final VoidCallback? onBack; final bool embedded; Future _copy(BuildContext context, String text, String toastLabel) async { await Clipboard.setData(ClipboardData(text: text)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(toastLabel), behavior: SnackBarBehavior.floating, duration: const Duration(milliseconds: 1400), )); } } // 验证领取:拉 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 inviteAsync = ref.watch(inviteProvider); Widget body = PageBody(child: 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)), )); return SubScaffold( title: t.inviteTitle, onBack: onBack, embedded: embedded, 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: info.code, mono: true, copyLabel: t.inviteCopyCode, onCopy: () => onCopy(context, info.code, t.copied), ), const SizedBox(height: 14), _CopyCard( icon: PangolinIcons.link, label: t.inviteLinkLabel, value: info.link, mono: false, copyLabel: t.inviteCopyLink, onCopy: () => onCopy(context, info.link, t.copied), ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), child: Text(t.inviteRewardsTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), ), Container( decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm, ), clipBehavior: Clip.antiAlias, child: Column(children: [ _RewardRow(icon: PangolinIcons.users, text: t.inviteRewardRegister), Divider(height: 1, color: c.border), _RewardRow(icon: PangolinIcons.crown, text: t.inviteRewardFirstBuy), Divider(height: 1, color: c.border), _RewardRow(icon: PangolinIcons.messageCircle, text: t.inviteRewardJoinTg), ]), ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), child: Text(t.inviteProgressTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), ), Container( width: double.infinity, decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm, ), 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), ], ], ); } } 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, required this.label, required this.value, required this.mono, required this.copyLabel, required this.onCopy, }); final IconData icon; final String label; final String value; final bool mono; final String copyLabel; final VoidCallback onCopy; @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(icon, size: 17, color: c.accent), ), const SizedBox(width: 10), Text(label, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13.5)), ]), const SizedBox(height: 12), Row(children: [ Expanded( child: Text(value, overflow: TextOverflow.ellipsis, style: mono ? PangolinText.mono.copyWith(color: c.fg1, fontWeight: FontWeight.w700, letterSpacing: 1.2, fontSize: 15) : PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), ), const SizedBox(width: 10), OutlinedButton.icon( onPressed: onCopy, style: OutlinedButton.styleFrom( foregroundColor: c.accent, side: BorderSide(color: c.accentBorder), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), ), icon: Icon(PangolinIcons.copy, size: 15), label: Text(copyLabel, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)), ), ]), ]), ); } } class _RewardRow extends StatelessWidget { const _RewardRow({required this.icon, required this.text}); final IconData icon; final String text; @override Widget build(BuildContext context) { final c = context.pangolin; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), child: Row(children: [ Container( width: 32, height: 32, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)), child: Icon(icon, size: 17, color: c.accent), ), const SizedBox(width: 13), Expanded(child: Text(text, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 14.5))), ]), ); } }