359 lines
13 KiB
Dart
359 lines
13 KiB
Dart
// 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<void> _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<void> _verifyAndClaim(BuildContext context, WidgetRef ref) async {
|
|
try {
|
|
final link = await ref.read(inviteApiProvider).telegramStartLink();
|
|
final uri = link.isEmpty ? null : Uri.tryParse(link);
|
|
if (uri == null) {
|
|
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
|
return;
|
|
}
|
|
final can = await canLaunchUrl(uri);
|
|
if (!can) {
|
|
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
|
return;
|
|
}
|
|
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
if (!ok && context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
|
if (ok) await ref.read(inviteProvider.notifier).refresh();
|
|
} catch (_) {
|
|
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final 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<void> Function(BuildContext, String, String) onCopy;
|
|
final Future<void> Function(BuildContext) onVerifyAndClaim;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
|
children: [
|
|
_CopyCard(
|
|
icon: PangolinIcons.gift,
|
|
label: t.inviteCodeLabel,
|
|
value: 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<void> Function(BuildContext) onVerifyAndClaim;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: c.surface,
|
|
borderRadius: BorderRadius.circular(PangolinRadius.xl),
|
|
border: Border.all(color: c.border),
|
|
boxShadow: PangolinShadow.sm,
|
|
),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Row(children: [
|
|
Container(
|
|
width: 32, height: 32,
|
|
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)),
|
|
child: Icon(PangolinIcons.send, size: 17, color: c.accent),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(t.inviteJoinTgTitle,
|
|
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 13.5)),
|
|
),
|
|
]),
|
|
const SizedBox(height: 14),
|
|
if (info.tgJoined)
|
|
PangolinButton(
|
|
label: t.inviteTaskDone,
|
|
icon: PangolinIcons.checkCircle,
|
|
variant: PangolinButtonVariant.secondary,
|
|
expand: true,
|
|
onPressed: null,
|
|
)
|
|
else
|
|
Column(children: [
|
|
PangolinButton(
|
|
label: t.inviteJoinTgBtn,
|
|
variant: PangolinButtonVariant.secondary,
|
|
expand: true,
|
|
onPressed: () => launchChannel(info.channel),
|
|
),
|
|
const SizedBox(height: 10),
|
|
PangolinButton(
|
|
label: t.inviteVerifyBtn,
|
|
variant: PangolinButtonVariant.primary,
|
|
expand: true,
|
|
onPressed: () => onVerifyAndClaim(context),
|
|
),
|
|
]),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CopyCard extends StatelessWidget {
|
|
const _CopyCard({
|
|
required this.icon,
|
|
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))),
|
|
]),
|
|
);
|
|
}
|
|
}
|