328d34a320
iPad 竖屏回落 mobile 底 Tab(无侧栏),设置入口够不着。在账户页「我的设备」下面加一行 「设置」→ 下钻 SettingsScreen(复用桌面 SettingsPage + 移动返回栏)。与「联系我们」一致: desktop/tablet 侧栏 + 账户页都可进。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
410 lines
15 KiB
Dart
410 lines
15 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 '../widgets/account_screens.dart';
|
|
import '../widgets/app_top_bar.dart';
|
|
import '../widgets/pangolin_icons.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 lang = ref.watch(localeProvider);
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
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 = ListView(
|
|
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
|
|
children: [
|
|
_PlanBanner(t: t, isFree: isFree, email: email, expiresLabel: expiresLabel, onUpgrade: () => open(NavView.plans, PlansScreen(t: t))),
|
|
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.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))),
|
|
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.shoppingBag, title: t.redeemEntry, onTap: () => open(NavView.redeem, RedeemScreen(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: [
|
|
_CustomRow(
|
|
icon: PangolinIcons.globe,
|
|
title: t.language,
|
|
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).state = l),
|
|
),
|
|
Divider(height: 1, color: c.border),
|
|
_CustomRow(
|
|
icon: isDark ? PangolinIcons.moon : PangolinIcons.sun,
|
|
title: t.darkAppearance,
|
|
subtitle: isDark ? t.stateOn : t.followLight,
|
|
trailing: _PangolinSwitch(
|
|
value: isDark,
|
|
onChanged: (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light,
|
|
),
|
|
),
|
|
Divider(height: 1, color: c.border),
|
|
_CustomRow(
|
|
icon: PangolinIcons.shield,
|
|
title: t.protocol,
|
|
trailing: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 13)),
|
|
),
|
|
]),
|
|
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),
|
|
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.danger = false});
|
|
final IconData icon;
|
|
final String title;
|
|
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: Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500))),
|
|
if (!danger) Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CustomRow extends StatelessWidget {
|
|
const _CustomRow({required this.icon, required this.title, this.subtitle, required this.trailing});
|
|
final IconData icon;
|
|
final String title;
|
|
final String? subtitle;
|
|
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)),
|
|
if (subtitle != null) Text(subtitle!, style: PangolinText.caption.copyWith(color: c.fg3)),
|
|
])),
|
|
trailing,
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 段控:中文 / EN(单显切换)。
|
|
class _LangSwitch extends StatelessWidget {
|
|
const _LangSwitch({required this.lang, required this.onChange});
|
|
final AppLang lang;
|
|
final ValueChanged<AppLang> onChange;
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
Widget seg(AppLang v, String label) {
|
|
final active = lang == v;
|
|
return GestureDetector(
|
|
onTap: () => onChange(v),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: active ? c.accent : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
|
),
|
|
child: Text(label,
|
|
style: PangolinText.caption.copyWith(
|
|
color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12.5)),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(3),
|
|
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
seg(AppLang.zh, '中文'),
|
|
const SizedBox(width: 2),
|
|
seg(AppLang.en, 'EN'),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PangolinSwitch extends StatelessWidget {
|
|
const _PangolinSwitch({required this.value, required this.onChanged});
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return GestureDetector(
|
|
onTap: () => onChanged(!value),
|
|
child: AnimatedContainer(
|
|
duration: PangolinMotion.base,
|
|
curve: PangolinMotion.easeOut,
|
|
width: 46, height: 28,
|
|
decoration: BoxDecoration(color: value ? c.accent : c.borderStrong, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
|
child: AnimatedAlign(
|
|
duration: PangolinMotion.base,
|
|
curve: PangolinMotion.easeOut,
|
|
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(3),
|
|
child: Container(width: 22, height: 22, decoration: const BoxDecoration(color: PangolinColors.white, shape: BoxShape.circle)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|