// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们) // // 文案全部经 AppText(l10n,单显)。套餐数字以 design/CLAUDE.md §7 为准。 // App 内无任何支付表单——购买仅引导至外部渠道。 import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../models/device.dart'; import '../pangolin_theme.dart'; import '../screens/settings_page.dart'; import '../services/device_identity.dart'; import 'adaptive_menu.dart'; import 'pangolin_field.dart'; import '../services/auth_api.dart'; import '../state/account_providers.dart'; import 'pangolin_button.dart'; import 'pangolin_icons.dart'; import 'plan_card.dart'; /// 通用:带返回箭头的子页骨架。embedded=true 时只返回内容(desktop 内容区下钻, /// 返回/标题由外层 shell 顶栏提供),否则整页 Scaffold(mobile/tablet 全屏 push)。 class _SubScaffold extends StatelessWidget { const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false}); final String title; final Widget child; final VoidCallback? onBack; final bool embedded; @override Widget build(BuildContext context) { final c = context.pangolin; if (embedded) return child; 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(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), ]), ), Expanded(child: child), ]), ), ); } } /// ── 设置(移动端从「我的」下钻)── 复用桌面 SettingsPage 内容,套移动返回栏。 class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key, required this.t, this.onBack}); final AppText t; final VoidCallback? onBack; @override Widget build(BuildContext context) { return _SubScaffold(title: t.settingsTitle, onBack: onBack, child: const SettingsPage()); } } /// ── 套餐选择 ── (价格/额度来自 GET /v1/plans;当前档由 me.plan 判定; /// 功能清单仍取 l10n,后端无此字段) class PlansScreen extends ConsumerWidget { const PlansScreen({super.key, required this.t, this.onChoose, this.onBack, this.embedded = false}); final AppText t; final ValueChanged? onChoose; final VoidCallback? onBack; final bool embedded; List _feats(String code) => switch (code) { 'free' => t.featsFree, 'pro' => t.featsPro, 'team' => t.featsTeam, _ => const [], }; // 套餐名是 UI 文案(后端 plans 表无名称列),按 code 取 l10n。 String _name(String code) => switch (code) { 'free' => t.freePlan, 'pro' => t.proPlan, 'team' => t.teamPlan, _ => code, }; @override Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; final plansAsync = ref.watch(plansProvider); final myPlan = ref.watch(meProvider).valueOrNull?.plan ?? 'free'; Widget body() => plansAsync.when( loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), error: (_, __) => Center( child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), data: (plans) => ListView( padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ for (final p in plans) Padding( padding: EdgeInsets.only(bottom: 14, top: p.code == 'pro' ? 12 : 0), child: PlanCard( name: _name(p.code), price: p.priceLabel(), period: t.perMonth, features: _feats(p.code), ctaLabel: p.code == myPlan ? t.current : (p.code == 'team' ? t.choose : t.upgrade), featured: p.code == 'pro', isCurrent: p.code == myPlan, popularLabel: p.code == 'pro' ? t.mostPopular : null, onPressed: () => onChoose?.call(p.code), ), ), ], ), ); return _SubScaffold(title: t.choosePlan, onBack: onBack, embedded: embedded, child: body()); } } /// ── 设备管理 ── (真实 GET /v1/me/devices;移除 = DELETE。本机高亮待 P6 设备 id) class DevicesScreen extends ConsumerWidget { const DevicesScreen({super.key, required this.t, this.onBack, this.embedded = false}); final AppText t; final VoidCallback? onBack; final bool embedded; IconData _icon(DevicePlatform p) => switch (p) { DevicePlatform.ios || DevicePlatform.android => PangolinIcons.smartphone, DevicePlatform.macos || DevicePlatform.windows || DevicePlatform.linux => PangolinIcons.laptop, DevicePlatform.unknown => PangolinIcons.monitorSmartphone, }; // 相对时间(最后登录);null → 从未登录。 String _rel(DateTime? ts) { final zh = t.lang == AppLang.zh; if (ts == null) return t.devNeverLogin; final d = DateTime.now().difference(ts.toLocal()); if (d.inMinutes < 1) return zh ? '刚刚' : 'just now'; if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago'; if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago'; return zh ? '${d.inDays} 天前' : '${d.inDays}d ago'; } @override Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; final devicesAsync = ref.watch(devicesProvider); final localId = ref.watch(localDeviceIdProvider).valueOrNull ?? ''; Widget content() => devicesAsync.when( loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), error: (_, __) => Center( child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), const SizedBox(height: 12), if (devices.isEmpty) Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet', style: PangolinText.sm.copyWith(color: c.fg3)) else 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: [ for (var i = 0; i < devices.length; i++) _row(context, ref, c, devices[i], devices[i].uuid == localId, i < devices.length - 1), ]), ), ]), ); return _SubScaffold( title: t.myDevices, onBack: onBack, embedded: embedded, child: _DevicesAutoRefresh(child: content())); } Widget _row(BuildContext context, WidgetRef ref, PangolinScheme c, Device d, bool isLocal, bool divider) { final name = d.name.isNotEmpty ? d.name : d.platformKind.label; final subParts = [d.platformKind.label]; if (d.clientVersion.isNotEmpty) subParts.add(d.clientVersion); // 离线设备显示「最后在线」——用 last_seen(连接/用量/~15s 会话轮询都会刷新), // 而非 last_login(仅登录那一刻);否则一直在线的设备会错显成登录时间(如 13 小时前)。 if (!d.online) subParts.add('${t.devLastOnline} ${_rel(d.lastSeen)}'); return Container( decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), child: Row(children: [ Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), child: Icon(_icon(d.platformKind), size: 19, color: c.accent)), const SizedBox(width: 12), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row(children: [ Flexible(child: Text(name, overflow: TextOverflow.ellipsis, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))), if (isLocal) ...[const SizedBox(width: 7), _badge(c, t.thisDevice)], ]), const SizedBox(height: 2), Text(subParts.join(' · '), overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), ]), ), const SizedBox(width: 8), _statusDot(c, d.online), _menu(context, ref, c, d, isLocal), ]), ); } Widget _badge(PangolinScheme c, String text) => Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(6), border: Border.all(color: c.accentBorder)), child: Text(text, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 10)), ); Widget _statusDot(PangolinScheme c, bool online) { final col = online ? c.success : c.fg3; return Row(mainAxisSize: MainAxisSize.min, children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: col, shape: BoxShape.circle, boxShadow: online ? [BoxShadow(color: c.success.withValues(alpha: 0.25), spreadRadius: 3)] : null, ), ), const SizedBox(width: 5), Text(online ? t.devOnline : t.devOffline, style: PangolinText.caption.copyWith(color: col, fontWeight: FontWeight.w600, fontSize: 12)), ]); } // 本机:只给「重命名」(自我踢下线/清除没意义);其他设备:重命名+强制退出+清除。 // 用 AdaptiveMenuButton:菜单按屏幕位置自动上/下弹、不遮挡 ⋯ 按钮。 Widget _menu(BuildContext context, WidgetRef ref, PangolinScheme c, Device d, bool isLocal) { Future confirmAction({required bool isClear}) async { final label = isClear ? t.devClearLogin : t.devForceLogout; final shown = d.name.isNotEmpty ? d.name : d.platformKind.label; final ok = await _confirm(context, c, title: '$label「$shown」', body: isClear ? t.devClearLoginConfirm : t.devForceLogoutConfirm, action: label); if (!ok) return; final n = ref.read(devicesProvider.notifier); if (isClear) { await n.remove(d.uuid); } else { await n.forceLogout(d.uuid); } } return AdaptiveMenuButton( icon: PangolinIcons.moreVertical, iconColor: c.fg3, items: [ AdaptiveMenuItem( icon: PangolinIcons.edit, label: t.devRename, onSelected: () async { final newName = await _renameDialog(context, c, d); if (newName != null && newName.trim().isNotEmpty) { await ref.read(devicesProvider.notifier).rename(d.uuid, newName.trim()); } }, ), if (!isLocal) ...[ AdaptiveMenuItem(icon: PangolinIcons.logOut, label: t.devForceLogout, onSelected: () => confirmAction(isClear: false)), AdaptiveMenuItem(icon: PangolinIcons.trash, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)), ], ], ); } // 危险操作二次确认弹窗(走 token 配色)。 Future _confirm(BuildContext context, PangolinScheme c, {required String title, required String body, required String action}) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: c.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), title: Row(children: [ Container(width: 34, height: 34, decoration: BoxDecoration(color: c.dangerSubtle, shape: BoxShape.circle), child: Icon(PangolinIcons.alertTriangle, size: 18, color: c.danger)), const SizedBox(width: 12), Expanded(child: Text(title, overflow: TextOverflow.ellipsis, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700))), ]), content: Text(body, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600))), TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(action, style: PangolinText.sm.copyWith(color: c.danger, fontWeight: FontWeight.w700))), ], ), ); return ok ?? false; } // 重命名弹窗:预填当前名,返回新名(取消返回 null)。 Future _renameDialog(BuildContext context, PangolinScheme c, Device d) { final ctl = TextEditingController(text: d.name); return showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: c.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), title: Text(t.devRename, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), content: TextField( controller: ctl, autofocus: true, maxLength: 64, style: PangolinText.sm.copyWith(color: c.fg1), decoration: InputDecoration( hintText: t.devRenameHint, counterText: '', hintStyle: PangolinText.sm.copyWith(color: c.fg3), ), onSubmitted: (v) => Navigator.pop(ctx, v), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600))), TextButton(onPressed: () => Navigator.pop(ctx, ctl.text), child: Text(t.devSave, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700))), ], ), ); } } /// ── 兑换 & 购买 ── (兑换码走 POST /v1/redeem,成功后刷新账户) class RedeemScreen extends ConsumerStatefulWidget { const RedeemScreen({super.key, required this.t, this.onBack, this.embedded = false}); final AppText t; final VoidCallback? onBack; final bool embedded; @override ConsumerState createState() => _RedeemScreenState(); } class _RedeemScreenState extends ConsumerState { final _code = TextEditingController(); bool _ok = false; bool _busy = false; String? _err; @override void dispose() { _code.dispose(); super.dispose(); } Future _redeem() async { final code = _code.text.trim(); if (code.isEmpty) return; setState(() { _busy = true; _err = null; }); try { await ref.read(accountApiProvider).redeem(code); // 订阅变化 → 刷新账户聚合,套餐横幅/额度随之更新。 await ref.read(meProvider.notifier).refresh(); if (mounted) setState(() => _ok = true); } on AuthApiException catch (e) { if (mounted) setState(() => _err = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn); } finally { if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) { final c = context.pangolin; final t = widget.t; final channels = [ (icon: PangolinIcons.shoppingBag, name: t.chStore, sub: 'shop.pangolin.vpn', accent: true), (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false), (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), (icon: PangolinIcons.mail, name: t.chEmail, sub: 'buy@pangolin.vpn', accent: false), ]; return _SubScaffold( title: t.redeemTitle, onBack: widget.onBack, embedded: widget.embedded, child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ 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: [ Text(t.redeemCodeTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)), const SizedBox(height: 12), if (_ok) Row(children: [ Icon(PangolinIcons.checkCircle, size: 20, color: c.success), const SizedBox(width: 9), Text(t.redeemOk, style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w600)), ]) else Row(children: [ Expanded(child: PangolinFieldBox( fill: c.bg, child: TextField( controller: _code, textCapitalization: TextCapitalization.characters, style: PangolinText.mono.copyWith(color: c.fg1, letterSpacing: 1.5), decoration: bareInputDecoration(contentPadding: const EdgeInsets.symmetric(vertical: 13), hintText: t.redeemPh, hintStyle: PangolinText.sm.copyWith(color: c.fg3)), onChanged: (_) => setState(() {}), ), )), const SizedBox(width: 10), PangolinButton(label: t.redeemBtn, onPressed: (_busy || _code.text.trim().length < 4) ? null : _redeem), ]), if (_err != null) ...[ const SizedBox(height: 10), Text(_err!, style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)), ], ]), ), const SizedBox(height: 22), Text(t.buyTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)), const SizedBox(height: 6), Text(t.buySub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)), const SizedBox(height: 14), for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _channel(c, ch.icon, ch.name, ch.sub, ch.accent)), ]), ); } Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) { return InkWell( borderRadius: BorderRadius.circular(PangolinRadius.lg), onTap: () {}, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm), child: Row(children: [ Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)), const SizedBox(width: 13), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), ])), Icon(PangolinIcons.externalLink, size: 16, color: c.fg3), ]), ), ); } } /// ── 联系我们 ── class ContactScreen extends StatelessWidget { const ContactScreen({super.key, required this.t, this.onBack}); final AppText t; final VoidCallback? onBack; @override Widget build(BuildContext context) { final c = context.pangolin; final channels = [ (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true), (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), (icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false), (icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false), ]; return _SubScaffold( title: t.contactTitle, onBack: onBack, child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ Text(t.contactIntro, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.6)), const SizedBox(height: 16), for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _contact(c, ch.icon, ch.name, ch.sub, ch.accent)), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(t.contactHoursTitle, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)), const SizedBox(height: 5), Text(t.contactHours, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), ]), ), ]), ); } Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) { return InkWell( borderRadius: BorderRadius.circular(PangolinRadius.lg), onTap: () {}, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm), child: Row(children: [ Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)), const SizedBox(width: 13), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), ])), Icon(PangolinIcons.externalLink, size: 16, color: c.fg3), ]), ), ); } } /// 透传式轮询包装器:在设备页期间每 15s 静默刷新设备列表(不闪 loading), /// 让其他设备的在线/状态变化 ~15s 内可见。原样返回 child,不改布局。 class _DevicesAutoRefresh extends ConsumerStatefulWidget { const _DevicesAutoRefresh({required this.child}); final Widget child; @override ConsumerState<_DevicesAutoRefresh> createState() => _DevicesAutoRefreshState(); } class _DevicesAutoRefreshState extends ConsumerState<_DevicesAutoRefresh> { Timer? _poll; @override void initState() { super.initState(); _poll = Timer.periodic(const Duration(seconds: 15), (_) { if (mounted) ref.read(devicesProvider.notifier).refresh(); }); } @override void dispose() { _poll?.cancel(); super.dispose(); } @override Widget build(BuildContext context) => widget.child; }