Files
pangolin/client/lib/screens/account_page.dart
T

363 lines
13 KiB
Dart

// account_page.dart — 账户页(套餐横幅 + 账户信息 + 购买/订单/兑换 + 邀请 + 设备/设置/联系)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/responsive/form_factor.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/account_providers.dart';
import '../state/app_providers.dart';
import '../state/auth_provider.dart';
import '../state/navigation_provider.dart';
import '../state/notices_provider.dart';
import '../widgets/account_screens.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/notification_bell.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_icons.dart';
import 'invite_page.dart';
import 'notifications_page.dart';
import 'orders_page.dart';
import 'payment_page.dart';
import 'purchase_page.dart';
/// 格式化到期日为 YYYY-MM-DD(本地时区)。
String _fmtDate(DateTime d) {
final l = d.toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${l.year}-${two(l.month)}-${two(l.day)}';
}
class AccountPage extends ConsumerWidget {
const AccountPage({super.key, required this.isWide});
final bool isWide;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
final isFree = ref.watch(isFreePlanProvider);
final me = ref.watch(meProvider).valueOrNull;
final email = (me?.email.isNotEmpty ?? false) ? me!.email : '';
final expiresLabel = me?.expiresAt != null ? _fmtDate(me!.expiresAt!) : '';
final pad = isWide ? 28.0 : 20.0;
// desktop 在内容区下钻(切 navView);mobile/tablet 全屏 push。
final isDesktop = context.formFactor == FormFactor.desktop;
void open(NavView v, Widget mobilePage) {
if (isDesktop) {
ref.read(navViewProvider.notifier).state = v;
} else {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => mobilePage));
}
}
final body = PageBody(child: ListView(
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
children: [
_PlanBanner(
t: t,
isFree: isFree,
email: email,
expiresLabel: expiresLabel,
// Renew/Upgrade 直达购买页(套餐 + 渠道 switch),不再经 PlansScreen。
onUpgrade: () => open(
NavView.purchase,
PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))),
),
),
),
const SizedBox(height: 18),
// 账户信息
_SectionLabel(text: t.accInfoTitle),
_Card(children: [
_InfoRow(icon: PangolinIcons.mail, title: t.accEmail, value: email, trailing: _changeHint(c, t)),
Divider(height: 1, color: c.border),
_InfoRow(icon: PangolinIcons.lock, title: t.accPassword, value: '••••••••••', trailing: _changeHint(c, t)),
]),
const SizedBox(height: 16),
// 购买套餐 / 我的订单 / 兑换码(独立一块,紧跟账户信息)
_Card(children: [
_NavRow(
icon: PangolinIcons.crown,
title: t.purchaseTitle,
onTap: () => open(
NavView.purchase,
PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) =>
PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))),
),
)),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.ticket, title: t.ordersTitle, onTap: () => open(NavView.orders, OrdersScreen(t: t))),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.shoppingBag, title: t.redeemEntry, onTap: () => open(NavView.redeem, RedeemScreen(t: t))),
]),
const SizedBox(height: 16),
// 邀请好友
_Card(children: [
_NavRow(
icon: PangolinIcons.gift,
title: t.inviteEntry,
subtitle: t.inviteEntrySub,
onTap: () => open(NavView.invite, InviteScreen(t: t)),
),
]),
const SizedBox(height: 16),
// 设备 / 设置 / 联系(设置/联系桌面侧栏已有,仅 mobile/tablet 在此下钻)
_Card(children: [
_NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))),
if (context.formFactor == FormFactor.mobile) ...[
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.settings, title: t.settingsTitle, onTap: () => open(NavView.settings, SettingsScreen(t: t))),
Divider(height: 1, color: c.border),
_NavRow(icon: PangolinIcons.messageCircle, title: t.contactEntry, onTap: () => open(NavView.contact, ContactScreen(t: t))),
],
]),
const SizedBox(height: 16),
_Card(children: [
_NavRow(
icon: PangolinIcons.logOut,
title: t.signOut,
danger: true,
// 清除本地令牌 → authProvider 置未登录 → 根据登录态回登录页。
onTap: () => ref.read(authProvider.notifier).logout(),
),
]),
],
));
if (isWide) return body;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
AppTopBar(
brand: t.brand,
trailing: NotificationBell(
hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0,
onTap: () => open(NavView.notifications, NotificationsScreen(t: t)),
),
),
PageTitle(title: t.meTitle),
Expanded(child: body),
]);
}
Widget _changeHint(PangolinScheme c, AppText t) =>
Text(t.accChange, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13));
}
class _PlanBanner extends StatelessWidget {
const _PlanBanner({
required this.t,
required this.isFree,
required this.email,
required this.expiresLabel,
required this.onUpgrade,
});
final AppText t;
final bool isFree;
final String email;
final String expiresLabel;
final VoidCallback onUpgrade;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
if (isFree) {
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: 44, height: 44,
decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.user, size: 22, color: c.fg2),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(email, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 2),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Text(t.freePlanName, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 11)),
),
])),
_UpgradeButton(label: t.upgradeBtn, onTap: onUpgrade),
]),
]),
);
}
// PRO 渐变横幅
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: [PangolinColors.clay600, PangolinColors.clay800],
),
borderRadius: BorderRadius.circular(PangolinRadius.xl),
boxShadow: PangolinShadow.md,
),
child: Row(children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(color: PangolinColors.white.withValues(alpha: 0.18), shape: BoxShape.circle),
child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(email, style: const TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)),
const SizedBox(height: 4),
Text(expiresLabel.isEmpty ? t.proMember : '${t.proMember} · $expiresLabel',
style: TextStyle(color: PangolinColors.white.withValues(alpha: 0.85), fontSize: 12)),
])),
FilledButton(
onPressed: onUpgrade,
style: FilledButton.styleFrom(
backgroundColor: PangolinColors.white,
foregroundColor: PangolinColors.clay700,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
child: Text(t.upgradeBtn, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
),
]),
);
}
}
class _UpgradeButton extends StatelessWidget {
const _UpgradeButton({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
backgroundColor: c.accent,
foregroundColor: c.fgOnAccent,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
child: Text(text, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return 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: children),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.icon, required this.title, required this.value, this.trailing});
final IconData icon;
final String title, value;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(children: [
_IconBox(icon: icon),
const SizedBox(width: 13),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 15)),
Text(value, style: PangolinText.caption.copyWith(color: c.fg3)),
])),
if (trailing != null) trailing!,
]),
);
}
}
class _NavRow extends StatelessWidget {
const _NavRow({required this.icon, required this.title, required this.onTap, this.subtitle, this.danger = false});
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback onTap;
final bool danger;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(children: [
Icon(icon, size: 20, color: danger ? c.danger : c.accent),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500)),
if (subtitle != null)
Text(subtitle!, style: PangolinText.caption.copyWith(color: c.fg3)),
],
),
),
if (!danger) Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
]),
),
);
}
}
class _IconBox extends StatelessWidget {
const _IconBox({required this.icon});
final IconData icon;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
width: 32, height: 32,
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)),
child: Icon(icon, size: 17, color: c.accent),
);
}
}